diff --git a/.changeset/brave-embeds-report.md b/.changeset/brave-embeds-report.md new file mode 100644 index 00000000000..4b47be0fca9 --- /dev/null +++ b/.changeset/brave-embeds-report.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": patch +--- + +Reported embed renderer load failures to the editor's `onError` handler diff --git a/.changeset/calm-frames-settle.md b/.changeset/calm-frames-settle.md new file mode 100644 index 00000000000..2782ba07b75 --- /dev/null +++ b/.changeset/calm-frames-settle.md @@ -0,0 +1,5 @@ +--- +"@tryghost/koenig-lexical": patch +--- + +Fixed the editor freezing on embed previews when the browser shows scrollbars that take up space diff --git a/.changeset/preview-sso-user-status.md b/.changeset/preview-sso-user-status.md new file mode 100644 index 00000000000..38aa37eca04 --- /dev/null +++ b/.changeset/preview-sso-user-status.md @@ -0,0 +1,5 @@ +--- +"@tryghost/adapter-base-sso": patch +--- + +Exposed optional user status so SSO adapters can reject inactive owners before first-run setup. diff --git a/.changeset/thick-jobs-fetch.md b/.changeset/thick-jobs-fetch.md new file mode 100644 index 00000000000..13f5ef25b64 --- /dev/null +++ b/.changeset/thick-jobs-fetch.md @@ -0,0 +1,5 @@ +--- +"@tryghost/kg-default-nodes": patch +--- + +Added URL transforms for embed card thumbnails so self-hosted thumbnails are stored with the site URL placeholder diff --git a/apps/admin-x-framework/src/api/automations.ts b/apps/admin-x-framework/src/api/automations.ts index 7c455bf2020..54c6b188a36 100644 --- a/apps/admin-x-framework/src/api/automations.ts +++ b/apps/admin-x-framework/src/api/automations.ts @@ -3,12 +3,12 @@ import { Meta, createMutation, createQuery, createQueryWithId } from '../utils/a import type { ReadonlyDeep } from 'type-fest'; export type AutomationStatus = 'active' | 'inactive'; -export const MAX_AUTOMATION_ACTIONS = 20; +export const MAX_AUTOMATION_ACTIONS = 50; export type Automation = { id: string; name: string; - description?: string; + description: string; /** @deprecated `slug` will be removed in the future. */ slug?: null | string; status: AutomationStatus; diff --git a/apps/admin-x-framework/test/unit/api/automations.test.ts b/apps/admin-x-framework/test/unit/api/automations.test.ts index ea031e5068e..1af5df5c914 100644 --- a/apps/admin-x-framework/test/unit/api/automations.test.ts +++ b/apps/admin-x-framework/test/unit/api/automations.test.ts @@ -23,6 +23,7 @@ const baseDetail = ( id: 'a1', slug: 'welcome', name: 'Welcome', + description: 'Welcome new members.', status: 'active', created_at: '2026-05-05T00:00:00.000Z', updated_at: '2026-05-05T00:00:00.000Z', diff --git a/apps/admin/src/automations/automations.test.tsx b/apps/admin/src/automations/automations.test.tsx index 740c9ce7ae2..3d1cd6e2730 100644 --- a/apps/admin/src/automations/automations.test.tsx +++ b/apps/admin/src/automations/automations.test.tsx @@ -70,6 +70,7 @@ const automations = [ { id: 'automation-id-1', name: 'Free member welcome flow', + description: 'Greet new free members.', slug: 'member-welcome-email-free', status: 'active' as const, stats: { @@ -81,6 +82,7 @@ const automations = [ { id: 'automation-id-2', name: 'Paid member welcome flow', + description: 'Welcome new paid members.', slug: 'member-welcome-email-paid', status: 'inactive' as const, stats: { diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index 085d20e32d2..18e93d476af 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -20,6 +20,7 @@ const automations = [ { id: 'automation-id-2', name: 'Paid member welcome flow', + description: 'Welcome new paid members after they start their subscription.', slug: 'member-welcome-email-paid', status: 'inactive' as const, stats: { @@ -31,6 +32,7 @@ const automations = [ { id: 'automation-id-3', name: 'Another automation', + description: '', status: 'inactive' as const, stats: { last_run_created_at: null, @@ -68,15 +70,12 @@ describe('AutomationsList', () => { vi.useRealTimers(); }); - it('renders fetched automations with API and fallback descriptions and status labels', () => { + it('renders server descriptions and status labels', () => { renderWithRouter(); expect(screen.getAllByTestId('automation-list-row')).toHaveLength(3); expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); expect(screen.getByText('Greet new free members.')).toBeInTheDocument(); - expect( - screen.queryByText('Welcome new free members after they sign up.'), - ).not.toBeInTheDocument(); expect(screen.getByText('Paid member welcome flow')).toBeInTheDocument(); expect( screen.getByText('Welcome new paid members after they start their subscription.'), diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index 4eaeefe1364..5d2d365a6b0 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -13,19 +13,6 @@ import { } from '@tryghost/shade/components'; import { cn, formatNumber, formatTimestamp } from '@tryghost/shade/utils'; -const AUTOMATION_DESCRIPTIONS: Record = { - 'member-welcome-email-free': 'Welcome new free members after they sign up.', - 'member-welcome-email-paid': 'Welcome new paid members after they start their subscription.', -}; - -const getAutomationDescription = (automation: AutomationBrowseItem): string | undefined => { - if (automation.description !== undefined) { - return automation.description; - } - - return automation.slug ? AUTOMATION_DESCRIPTIONS[automation.slug] : undefined; -}; - // Widths are scoped to `lg` because below that the stats lay out on the row's // grid rather than in table cells, where a fixed width would fight the columns. const AUTOMATION_STAT_COLUMNS = [ @@ -145,7 +132,7 @@ const AutomationsList: React.FC = ({ )} {automations.map((automation) => { - const description = getAutomationDescription(automation); + const description = automation.description.trim(); const lastEntry = automation.stats?.last_run_created_at; const totalEntries = automation.stats?.total_run_count ?? 0; const inProgressEntries = automation.stats?.in_progress_run_count ?? 0; diff --git a/apps/admin/src/automations/editor.test.tsx b/apps/admin/src/automations/editor.test.tsx index 310e7d0fec8..0d7c10eceb0 100644 --- a/apps/admin/src/automations/editor.test.tsx +++ b/apps/admin/src/automations/editor.test.tsx @@ -376,6 +376,7 @@ const automationDetail: AutomationDetail = { id: 'automation-id-1', slug: 'member-welcome-email-free', name: 'Free member welcome flow', + description: 'Welcome new free members.', status: 'active', created_at: '2026-05-05T00:00:00.000Z', updated_at: '2026-05-05T00:00:00.000Z', diff --git a/apps/admin/src/editor/README.md b/apps/admin/src/editor/README.md index 35e44f8109c..bb476f84927 100644 --- a/apps/admin/src/editor/README.md +++ b/apps/admin/src/editor/README.md @@ -49,6 +49,13 @@ members" while the email is on its way and "Published and sent to N members" once the flow's email confirmation finds it submitted; an email-only send reads "Sent to N members" throughout. +After successful completion, the editor follows the publish flow's celebration +handoff to the destination screen. Pages return to `/pages`; scheduled posts +and posts without email return to `/posts`. Immediately published posts with +email, including email-only sends and posts that were emailed previously, open +`/posts/analytics/:id`. Failed saves and failed sends keep the flow open so the +writer can retry. + ## Adding a settings section 1. Add the section's id to `SETTINGS_SECTION_ORDER` in `settings/sections.ts`, @@ -89,6 +96,10 @@ specific to the editor lives in `apps/admin/test-utils/acceptance/editor.ts`: server that rewrites what it was sent. - `submittedPost(capture, index)` reads the fields of a captured save, the most recent one by default. +- `editorReadLanded(queryClient, version)` resolves once a read of `version` at + its `updated_at` is in the query cache and the editor has handled it, which is + the only sign of a read the editor refuses. `renderAdminApp` resolves with the + `queryClient`. - `fakeUnsplashPhotos()` serves one photo in the shape the picker lays out and inserts, and `UNSPLASH_PICKED` is the rendition the picker asks for. diff --git a/apps/admin/src/editor/date-time-picker.tsx b/apps/admin/src/editor/date-time-picker.tsx index 60c7b122693..9d1c6f4edee 100644 --- a/apps/admin/src/editor/date-time-picker.tsx +++ b/apps/admin/src/editor/date-time-picker.tsx @@ -26,6 +26,7 @@ export interface DateTimePickerProps { /** Calendar days after this instant's site-timezone day are not selectable. */ maxDate?: string | null; disabled?: boolean; + className?: string; dateLabel: string; timeLabel: string; dateTestId: string; @@ -47,6 +48,7 @@ export function DateTimePicker({ minDate, maxDate, disabled = false, + className, dateLabel, timeLabel, dateTestId, @@ -108,7 +110,13 @@ export function DateTimePicker({ const describedByProps = describedBy ? { 'aria-describedby': describedBy } : {}; return ( - + diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx index 640649fcbc1..1dcfb2bbb67 100644 --- a/apps/admin/src/editor/editor-header-actions.tsx +++ b/apps/admin/src/editor/editor-header-actions.tsx @@ -1,4 +1,6 @@ import { useCallback, useState } from 'react'; +import { useEmberOwnedRouteMatcher } from '@/routes'; +import { useNavigate } from '@tryghost/admin-x-framework'; import { Button } from '@tryghost/shade/components'; import { useShade } from '@tryghost/shade/app'; import { PageHeader } from '@tryghost/shade/patterns'; @@ -88,7 +90,12 @@ export function EditorHeaderActions({ const isDraft = post.status === 'draft'; usePreviewShortcut( - useCallback(() => setPreviewOpen((open) => !open), []), + useCallback(() => { + setPreviewOpen(!previewOpen); + if (previewOpen) { + onOpenFlow('none'); + } + }, [onOpenFlow, previewOpen]), isDraft && persistedId !== null, ); @@ -199,6 +206,8 @@ function PublishActions({ onOpenFlow, onPreview, }: PublishActionsProps) { + const navigate = useNavigate(); + const isEmberOwned = useEmberOwnedRouteMatcher(); const { isAdmin7 } = useShade(); const inputs = usePublishInputs(); const limits = usePublishLimits(); @@ -210,6 +219,7 @@ function PublishActions({ }); // A refetch of any input must not unmount an open flow, so readiness latches once. const [everReady, setEverReady] = useState(false); + const [openedFromPreview, setOpenedFromPreview] = useState(false); if (inputs.isReady && !everReady) { setEverReady(true); @@ -226,10 +236,26 @@ function PublishActions({ onOpenFlow('none'); void session.dispatchPublish({ kind: 'revert' }); }, [onOpenFlow, session]); - const closeFlow = useCallback(() => onOpenFlow('none'), [onOpenFlow]); - const openPublishFlow = useCallback(() => onOpenFlow('publish'), [onOpenFlow]); + const closeFlow = useCallback(() => { + setOpenedFromPreview(false); + onOpenFlow('none'); + }, [onOpenFlow]); + const openPublishFlow = useCallback(() => { + setOpenedFromPreview(false); + onOpenFlow('publish'); + }, [onOpenFlow]); const { onOpenChange: setPreviewOpen } = preview; + const changePreviewOpen = useCallback( + (open: boolean) => { + setPreviewOpen(open); + if (!open) { + closeFlow(); + } + }, + [closeFlow, setPreviewOpen], + ); const publishFromPreview = useCallback(() => { + setOpenedFromPreview(true); setPreviewOpen(false); onOpenFlow('publish'); }, [onOpenFlow, setPreviewOpen]); @@ -275,7 +301,9 @@ function PublishActions({ @@ -306,10 +334,12 @@ function PublishActions({ {openFlow === 'publish' && everReady ? ( { + const destination = + post.displayName === 'page' + ? '/pages' + : !isScheduled && (hasEmail || post.email || post.emailOnly) + ? `/posts/analytics/${postId}` + : '/posts'; + navigate(destination, { crossApp: isEmberOwned(destination) }); + }} onPreview={onPreview} onRevertToDraft={revertToDraft} /> diff --git a/apps/admin/src/editor/editor-header.acceptance.test.tsx b/apps/admin/src/editor/editor-header.acceptance.test.tsx index a2c7ebc863c..20663adb889 100644 --- a/apps/admin/src/editor/editor-header.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-header.acceptance.test.tsx @@ -6,10 +6,14 @@ import { publishTypeError } from '@tryghost/test-data/selectors/editor'; import { browseResponse, configResponse, + currentRoute, currentUserResponse, fakeAdminEndpoint, + fakeAdminStats, fakeNewsletters, + fakePages, fakePosts, + fakePostsListScreen, fakeSnippets, fakeTiers, newsletter, @@ -29,7 +33,7 @@ import { publishScreen } from '@/editor/publish/publish.screen'; const POST_ID = 'abc123'; const POST_UUID = 'post-uuid'; -const FLAG_ON = { labs: { editorReact: true } }; +const FLAG_ON = { labs: { editorReact: true, postsListReact: true } }; const LOADED_AT = '2026-01-01T00:00:00.000Z'; const SITE_URL = 'http://test.com'; @@ -77,6 +81,12 @@ function failureBody(status: number) { function publishChrome({ newsletters = 0 } = {}) { fakeSnippets([]); fakePosts([]); + fakePages([]); + fakePostsListScreen(); + // Successful sends leave the editor for the post's analytics screen. + fakeAdminStats.postReferrers(POST_ID, []); + fakeAdminStats.postGrowth(POST_ID); + fakeAdminStats.mrr(); fakeTiers([]); fakeNewsletters( Array.from({ length: newsletters }, () => @@ -117,7 +127,11 @@ function isEmailConfirmationRead(url: string): boolean { */ function fakeSavablePost( overrides: Partial = {}, - { failWith = 0, holdFirstSave }: { failWith?: number; holdFirstSave?: Promise } = {}, + { + failWith = 0, + holdFirstSave, + resource = 'posts', + }: { failWith?: number; holdFirstSave?: Promise; resource?: 'posts' | 'pages' } = {}, ) { let current = post({ id: POST_ID, @@ -139,17 +153,17 @@ function fakeSavablePost( slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }], })); - fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), ({ url }) => { + fakeAdminEndpoint('GET', new RegExp(`^/${resource}/${POST_ID}/\\?`), ({ url }) => { // The send has gone out by the time the flow's email confirmation reads the post. if (current.email?.status === 'pending' && isEmailConfirmationRead(url)) { current.email = { ...current.email, status: 'submitted', error: null }; } - return { posts: [current] }; + return { [resource]: [current] }; }); const saveApi = fakeAdminEndpoint( 'PUT', - new RegExp(`^/posts/${POST_ID}/\\?`), + new RegExp(`^/${resource}/${POST_ID}/\\?`), async ({ body, url }) => { saves += 1; @@ -161,7 +175,7 @@ function fakeSavablePost( return failureBody(failWith); } - const submitted = (body as { posts: Partial[] }).posts[0]; + const submitted = (body as Record[]>)[resource][0]; current = { ...current, ...submitted, updated_at: `2026-01-01T00:00:0${saves}.000Z` }; // Core creates a send's email pending and hands it over in the background. @@ -169,7 +183,7 @@ function fakeSavablePost( current.email = { id: 'email-1', status: 'pending', email_count: 20, opened_count: 0 }; } - return { posts: [current] }; + return { [resource]: [current] }; }, { status: failWith || 200 }, ); @@ -227,6 +241,7 @@ afterEach(() => { localStorage.removeItem('ghost-last-published-post'); localStorage.removeItem('ghost-last-scheduled-post'); delete window.EmberBridge; + delete document.body.dataset.externalNavigate; }); /** @@ -235,7 +250,7 @@ afterEach(() => { * make. */ describe('Editor header actions', () => { - it('publishes a draft and keeps the editor open on the new status', async () => { + it('returns to the post list after publishing without email', async () => { publishChrome(); const saveApi = fakeSavablePost(); await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); @@ -243,18 +258,35 @@ describe('Editor header actions', () => { await expect.element(editorScreen.publishButton()).toBeEnabled(); await publishThroughFlow(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(submittedPost(saveApi)).toMatchObject({ id: POST_ID, status: 'published' }); // The header never PUTs on its own: the publish is the only write. expect(saveApi.requests).toHaveLength(1); - await expect.element(editorScreen.root()).toBeVisible(); - await expect.element(editorScreen.status()).toHaveTextContent('Published'); - // The header's controls follow the acknowledged status through the session view. - await expect.element(editorScreen.updateButton()).toBeVisible(); - await expect(editorScreen.previewButton()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); }); + it.each(['post', 'page'] as const)( + 'hands completed %s publishing to Ember when it owns the destination list', + async (postType) => { + publishChrome(); + const resource = postType === 'page' ? 'pages' : 'posts'; + fakeSavablePost({}, { resource }); + await renderAdminApp(`/editor/${postType}/${POST_ID}`, { + labs: { editorReact: true, postsListReact: false }, + }); + + await publishThroughFlow(); + + await expect + .poll((): unknown => JSON.parse(document.body.dataset.externalNavigate ?? 'null')) + .toMatchObject({ route: `/${resource}`, isExternal: true }); + // The harness records the handoff; the pending flow stays until Ember navigates. + await expect.element(publishScreen.confirmButton()).toBeDisabled(); + await expect(publishScreen.complete()).toHaveCount(0); + }, + ); + it('sends the newsletter the publish flow selected', async () => { publishChrome({ newsletters: 1 }); const saveApi = fakeSavablePost(); @@ -263,13 +295,13 @@ describe('Editor header actions', () => { await expect.element(editorScreen.publishButton()).toBeEnabled(); await publishThroughFlow(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(submittedPost(saveApi)).toMatchObject({ status: 'published' }); expect(saveApi.lastRequest?.url).toContain('newsletter=weekly'); expect(saveApi.lastRequest?.url).toContain('email_segment=all'); }); - it('follows an emailed publish to sent once the flow confirms the send', async () => { + it('opens post analytics once the flow confirms the email send', async () => { publishChrome({ newsletters: 1 }); const saveApi = fakeSavablePost(); await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); @@ -282,12 +314,9 @@ describe('Editor header actions', () => { await publishScreen.continueButton().click(); await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(saveApi.lastRequest?.url).toContain('newsletter=weekly'); - // The editor's own reads saw the email pending; only the confirmation found it sent. - await expect - .element(editorScreen.status()) - .toHaveTextContent('Published and sent to 20 members'); + await expect(editorScreen.root()).toHaveCount(0); }); it('schedules a draft for the time the flow chose', async () => { @@ -301,12 +330,74 @@ describe('Editor header actions', () => { await publishScreen.continueButton().click(); await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); const submitted = submittedPost(saveApi); expect(submitted).toMatchObject({ status: 'scheduled' }); expect(Date.parse(String(submitted.published_at))).toBeGreaterThan(Date.now()); }); + it('returns scheduled emails to the post list instead of analytics', async () => { + publishChrome({ newsletters: 1 }); + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + + await editorScreen.publishButton().click(); + await publishScreen.setting('publish-at').click(); + await page.getByLabelText('Schedule for later').click(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.poll(currentRoute).toBe('/posts'); + await expect(editorScreen.root()).toHaveCount(0); + }); + + it.each([false, true])('returns pages to the page list (scheduled: %s)', async (scheduled) => { + publishChrome(); + fakeSavablePost({}, { resource: 'pages' }); + await renderAdminApp(`/editor/page/${POST_ID}`, FLAG_ON); + + await editorScreen.publishButton().click(); + if (scheduled) { + await publishScreen.setting('publish-at').click(); + await page.getByLabelText('Schedule for later').click(); + } + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.poll(currentRoute).toBe('/pages'); + await expect(editorScreen.root()).toHaveCount(0); + }); + + it('opens analytics after an email-only send', async () => { + publishChrome({ newsletters: 1 }); + const saveApi = fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + + await editorScreen.publishButton().click(); + await publishScreen.setting('publish-type').click(); + await page.getByLabelText('Email only').click(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); + expect(submittedPost(saveApi)).toMatchObject({ status: 'published', email_only: true }); + await expect(editorScreen.root()).toHaveCount(0); + }); + + it('opens analytics when republishing a previously emailed post', async () => { + publishChrome({ newsletters: 1 }); + const saveApi = fakeSavablePost({ + email: { id: 'email-1', status: 'submitted', email_count: 20, opened_count: 0 }, + }); + await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON); + + await publishThroughFlow(); + + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); + expect(saveApi.lastRequest?.url).not.toContain('newsletter='); + await expect(editorScreen.root()).toHaveCount(0); + }); + it('saves unsaved work on a published post through Update', async () => { publishChrome(); const saveApi = fakeSavablePost({ @@ -595,6 +686,36 @@ describe('Editor header actions', () => { await expect(previewScreen.modal()).toHaveCount(0); }); + it('animates opening from the editor but switches fullscreen surfaces without animation', async () => { + publishChrome(); + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await editorScreen.previewButton().click(); + await expect.element(previewScreen.modal()).toBeVisible(); + expect(getComputedStyle(previewScreen.modal().element()).animationName).not.toBe('none'); + await previewScreen.publishButton().click(); + await expect(previewScreen.modal()).toHaveCount(0); + await expect.element(publishScreen.options()).toBeVisible(); + expect(getComputedStyle(publishScreen.root().element()).animationName).toBe('none'); + + await publishScreen.previewButton().click(); + await expect.element(previewScreen.modal()).toBeVisible(); + expect(getComputedStyle(previewScreen.modal().element()).animationName).toBe('none'); + await previewScreen.publishButton().click(); + await expect(previewScreen.modal()).toHaveCount(0); + expect(getComputedStyle(publishScreen.root().element()).animationName).toBe('none'); + + await publishScreen.closeButton().click(); + await editorScreen.publishButton().click(); + await expect.element(publishScreen.options()).toBeVisible(); + expect(getComputedStyle(publishScreen.root().element()).animationName).not.toBe('none'); + await publishScreen.closeButton().click(); + await editorScreen.previewButton().click(); + await expect.element(previewScreen.modal()).toBeVisible(); + expect(getComputedStyle(previewScreen.modal().element()).animationName).not.toBe('none'); + }); + it('keeps the publish flow and its choices while previewing from inside it', async () => { publishChrome({ newsletters: 1 }); const saveApi = fakeSavablePost(); @@ -614,7 +735,7 @@ describe('Editor header actions', () => { await publishScreen.continueButton().click(); await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(submittedPost(saveApi)).toMatchObject({ status: 'published' }); expect(saveApi.lastRequest?.url).not.toContain('newsletter='); }); @@ -653,7 +774,7 @@ describe('Editor header actions', () => { await publishScreen.confirmButton().click(); // A remounted flow would default back to publishing and emailing. - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(saveApi.lastRequest?.url).not.toContain('newsletter='); }); @@ -702,21 +823,32 @@ describe('Editor header actions', () => { await expect(editorScreen.publishInputsError()).toHaveCount(0); }); - it('returns to the publish flow when the preview it opened is closed', async () => { - publishChrome(); - fakeSavablePost(); - await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + it.each(['Close', 'Escape', 'preview shortcut'])( + 'returns to the editor when a preview opened from Publish is dismissed with %s', + async (closeWith) => { + publishChrome(); + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); - await editorScreen.publishButton().click(); - await publishScreen.previewButton().click(); - await expect.element(previewScreen.modal()).toBeVisible(); + await editorScreen.publishButton().click(); + await publishScreen.previewButton().click(); + await expect.element(previewScreen.modal()).toBeVisible(); - await previewScreen.closeButton().click(); + if (closeWith === 'Close') { + await previewScreen.closeButton().click(); + } else { + await userEvent.keyboard(closeWith === 'Escape' ? '{Escape}' : '{Meta>}p{/Meta}'); + } - await expect(previewScreen.modal()).toHaveCount(0); - await expect.element(publishScreen.options()).toBeVisible(); - await expect.element(publishScreen.previewButton()).toHaveFocus(); - }); + await expect(previewScreen.modal()).toHaveCount(0); + await expect(publishScreen.root()).toHaveCount(0); + await expect.element(editorScreen.publishButton()).toHaveFocus(); + + await editorScreen.publishButton().click(); + await expect.element(publishScreen.options()).toBeVisible(); + expect(getComputedStyle(publishScreen.root().element()).animationName).not.toBe('none'); + }, + ); it('publishes from a preview opened by the header Preview button', async () => { publishChrome(); @@ -777,7 +909,7 @@ describe('Editor header actions', () => { await typeIntoBody(' and more'); await publishThroughFlow(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(saveApi.requests).toHaveLength(2); expect(submittedPost(saveApi, 0)).toMatchObject({ status: 'draft' }); expect(saveApi.requests[0].url).toContain('save_revision=true'); @@ -827,7 +959,7 @@ describe('Editor header actions', () => { await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(submittedPost(restoredApi)).toMatchObject({ id: POST_ID, status: 'published' }); expect(restoredApi.requests).toHaveLength(1); }); @@ -1010,7 +1142,7 @@ describe('Editor header actions', () => { await expect.element(editorScreen.publishButton()).toBeEnabled(); await publishThroughFlow(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(saveApi.lastRequest?.url).toContain('newsletter=weekly'); // The count only covers the period the limit measures. expect(emailsApi.lastRequest?.url).toContain('filter=created_at%3A%3E%3D%27'); @@ -1051,7 +1183,7 @@ describe('Editor header actions', () => { await publishScreen.continueButton().click(); await publishScreen.confirmButton().click(); - await expect.element(publishScreen.complete()).toBeVisible(); + await expect.poll(currentRoute).toBe('/posts'); expect(saveApi.lastRequest?.url).not.toContain('newsletter='); }); diff --git a/apps/admin/src/editor/editor-newsletter-retry.acceptance.test.tsx b/apps/admin/src/editor/editor-newsletter-retry.acceptance.test.tsx index 087f092e3d7..474838ac56c 100644 --- a/apps/admin/src/editor/editor-newsletter-retry.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-newsletter-retry.acceptance.test.tsx @@ -3,8 +3,10 @@ import { buildLexicalParagraph } from '@tryghost/test-data'; import { browseResponse, + currentRoute, currentUserResponse, fakeAdminEndpoint, + fakeAdminStats, fakeNewsletters, fakePosts, fakeSnippets, @@ -34,6 +36,10 @@ function publishChrome() { fakeSnippets([]); fakePosts([]); fakeNewsletters(weeklyNewsletters()); + // Successful retries leave the editor for the post's analytics screen. + fakeAdminStats.postReferrers(POST_ID, []); + fakeAdminStats.postGrowth(POST_ID); + fakeAdminStats.mrr(); // The site-wide member total the publish machine reads; the boot entry counts a different shape. fakeAdminEndpoint('GET', /^\/members\/\?.*order=id/, { members: [], @@ -122,14 +128,10 @@ describe('Editor newsletter retry', () => { await expect.element(publishScreen.emailError()).toHaveTextContent(error || 'Unknown error'); await publishScreen.retryEmailButton().click(); - await expect - .element(publishScreen.complete()) - .toHaveTextContent('Your post has been published.'); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(retryApi.requests).toHaveLength(1); - await expect - .element(editorScreen.status()) - .toHaveTextContent('Published and sent to 20 members'); - await expect(editorScreen.viewNewsletterDetails()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); + await expect(publishScreen.root()).toHaveCount(0); }, ); @@ -150,10 +152,10 @@ describe('Editor newsletter retry', () => { await expect.element(publishScreen.emailError()).toHaveTextContent(error || 'Unknown error'); await publishScreen.retryEmailButton().click(); - await expect.element(publishScreen.complete()).toHaveTextContent('Your email has been sent.'); + await expect.poll(currentRoute).toBe(`/posts/analytics/${POST_ID}`); expect(retryApi.requests).toHaveLength(1); - await expect.element(editorScreen.status()).toHaveTextContent('Sent to 20 members'); - await expect(editorScreen.retryNewsletter()).toHaveCount(0); + await expect(editorScreen.root()).toHaveCount(0); + await expect(publishScreen.root()).toHaveCount(0); }, ); diff --git a/apps/admin/src/editor/editor-refetch.acceptance.test.tsx b/apps/admin/src/editor/editor-refetch.acceptance.test.tsx new file mode 100644 index 00000000000..34aab9ec778 --- /dev/null +++ b/apps/admin/src/editor/editor-refetch.acceptance.test.tsx @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { buildLexicalParagraph } from '@tryghost/test-data'; + +import { + editorReadLanded, + fakeAdminEndpoint, + fakeEditorChrome, + post, + renderAdminApp, + submittedPost, + unsavedChangesGuarded, + withoutAutosave, + type Post, +} from '@test-utils/acceptance'; +import { editorScreen } from '@/editor/editor.screen'; +import { deferred } from '@/utils/deferred'; + +const POST_ID = 'abc123'; +const FLAG_ON = withoutAutosave({ labs: { editorReact: true } }); +const LOADED_AT = '2026-01-01T00:00:00.000Z'; +const MY_SAVE_AT = '2026-01-01T00:00:01.000Z'; +const THEIR_SAVE_AT = '2026-01-01T00:00:02.000Z'; +const READ_ROUTE = new RegExp(`^/posts/${POST_ID}/\\?`); + +const UPDATE_COLLISION = { + errors: [ + { + code: 'UPDATE_COLLISION', + type: 'UpdateCollisionError', + message: 'Saving failed! Someone else is editing this post.', + }, + ], +}; + +/** + * A post two writers share: reads serve the stored copy and a save on a stale token is refused. + * Core is laxer: it refuses one only when a posts-row column or its tags, authors or tiers change. + */ +function fakeSharedPost(overrides: Partial = {}) { + fakeEditorChrome(); + fakeAdminEndpoint('GET', /^\/slugs\/post\//, ({ url }) => ({ + slugs: [{ slug: decodeURIComponent(url.split('/slugs/post/')[1].split('/')[0]) }], + })); + + let stored = post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'draft', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: LOADED_AT, + published_at: null, + tags: [], + ...overrides, + }); + const store = (changes: Partial) => { + stored = { + ...stored, + ...changes, + updated_at: new Date(Date.parse(stored.updated_at) + 1000).toISOString(), + }; + }; + + let readsHeld = Promise.resolve(); + const readApi = fakeAdminEndpoint('GET', READ_ROUTE, async () => { + await readsHeld; + return { posts: [stored] }; + }); + const saveApi = fakeAdminEndpoint('PUT', READ_ROUTE, ({ body }) => { + const submitted = (body as { posts: Partial[] }).posts[0]; + if (submitted.updated_at !== stored.updated_at) { + return Response.json(UPDATE_COLLISION, { status: 409 }); + } + store(submitted); + return { posts: [stored] }; + }); + + return { + readApi, + saveApi, + stored: () => stored, + /** Another writer's save, stored under a token this tab has never been sent. */ + theySave: (changes: Partial) => store(changes), + /** Holds every read until the returned release is called. */ + holdReads: () => { + const gate = deferred(); + readsHeld = gate.promise; + return () => gate.resolve(); + }, + }; +} + +type SharedPost = ReturnType; + +async function appendToBody(text: string) { + const body = editorScreen.body(); + await expect.element(body).toBeVisible(); + await body.fill(`${body.element().textContent ?? ''}${text}`); +} + +/** This tab's title save lands; the other writer's save lands before its refetch is read. */ +async function saveThenTheySave(shared: SharedPost, save: () => Promise) { + await expect.element(editorScreen.titleInput()).toHaveValue('Hello from React'); + const releaseReads = shared.holdReads(); + + await editorScreen.titleInput().fill('My title'); + await save(); + await expect.poll(() => shared.saveApi.requests.length).toBe(1); + await expect.poll(() => shared.readApi.requests.length).toBe(2); + + shared.theySave({ + title: 'Their title', + lexical: buildLexicalParagraph('Their words'), + }); + return releaseReads; +} + +const saveShortcut = () => userEvent.keyboard('{Meta>}s{/Meta}'); + +/** + * A background read can bring back another writer's version; the editor keeps its + * own, so its next save collides with theirs instead of overwriting it. + */ +describe('Post editor refetch', () => { + it('saves a draft against its own version after a refetch brings another writer’s', async () => { + const shared = fakeSharedPost(); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + const releaseReads = await saveThenTheySave(shared, saveShortcut); + releaseReads(); + await editorReadLanded(queryClient, shared.stored()); + + await appendToBody(' and mine'); + await saveShortcut(); + + await expect.poll(() => shared.saveApi.requests.length).toBe(2); + expect(submittedPost(shared.saveApi)).toMatchObject({ updated_at: MY_SAVE_AT }); + await expect + .element(editorScreen.conflictBanner()) + .toHaveTextContent('Someone else is editing this post'); + await expect.element(editorScreen.titleInput()).toHaveValue('My title'); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React and mine'); + expect(shared.stored()).toMatchObject({ title: 'Their title', updated_at: THEIR_SAVE_AT }); + }); + + it('keeps a title staged on a published post through the refetch, and the Update collides', async () => { + const shared = fakeSharedPost({ + status: 'published', + published_at: '2025-12-01T00:00:00.000Z', + }); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + const releaseReads = await saveThenTheySave(shared, () => editorScreen.updateButton().click()); + await editorScreen.titleInput().fill('My staged title'); + releaseReads(); + await editorReadLanded(queryClient, shared.stored()); + + await editorScreen.updateButton().click(); + + await expect.poll(() => shared.saveApi.requests.length).toBe(2); + expect(submittedPost(shared.saveApi)).toMatchObject({ + title: 'My staged title', + updated_at: MY_SAVE_AT, + }); + await expect.element(editorScreen.conflictBanner()).toBeVisible(); + await expect.element(editorScreen.titleInput()).toHaveValue('My staged title'); + expect(shared.stored()).toMatchObject({ title: 'Their title', updated_at: THEIR_SAVE_AT }); + }); + + it('leaves a clean writer clean and unsaved when a refetch brings another writer’s version', async () => { + const shared = fakeSharedPost(); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + const releaseReads = await saveThenTheySave(shared, saveShortcut); + await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); + releaseReads(); + await editorReadLanded(queryClient, shared.stored()); + + await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); + expect(unsavedChangesGuarded()).toBe(false); + await expect(editorScreen.conflictBanner()).toHaveCount(0); + await expect.element(editorScreen.titleInput()).toHaveValue('My title'); + expect(shared.saveApi.requests).toHaveLength(1); + }); + + it('saves again without a conflict once the refetch of its own save has landed', async () => { + const shared = fakeSharedPost(); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await expect.element(editorScreen.titleInput()).toHaveValue('Hello from React'); + + await editorScreen.titleInput().fill('My title'); + await saveShortcut(); + await expect.poll(() => shared.saveApi.requests.length).toBe(1); + await editorReadLanded(queryClient, shared.stored()); + + await appendToBody(' and more'); + await saveShortcut(); + + await expect.poll(() => shared.saveApi.requests.length).toBe(2); + expect(submittedPost(shared.saveApi)).toMatchObject({ updated_at: MY_SAVE_AT }); + await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); + await expect(editorScreen.conflictBanner()).toHaveCount(0); + expect(shared.stored()).toMatchObject({ title: 'My title' }); + expect(shared.stored().lexical).toContain('Hello from React and more'); + }); +}); diff --git a/apps/admin/src/editor/editor-save.acceptance.test.tsx b/apps/admin/src/editor/editor-save.acceptance.test.tsx index 114c62cfa57..fa1119b7e9c 100644 --- a/apps/admin/src/editor/editor-save.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-save.acceptance.test.tsx @@ -5,6 +5,7 @@ import { buildLexicalParagraph } from '@tryghost/test-data'; import { currentRoute, currentUserResponse, + editorReadLanded, fakeAdminEndpoint, fakeEditorChrome, fakeEditorPost, @@ -328,32 +329,62 @@ describe('Post editor saving', () => { await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); }); - it('reports a status the post reached elsewhere once a save refetches it', async () => { + it('saves its own version when a refetch finds the post published elsewhere, and collides', async () => { const saveApi = fakeSavablePost(); - await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + const { queryClient } = await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React'); await expect.element(editorScreen.status()).toHaveTextContent('Draft - Saved'); // A later handler for the same route wins: from here the read answers // with the post as someone else has just published it. - fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { - posts: [ - post({ - id: POST_ID, - title: 'Hello from React', - slug: 'hello-from-react', - status: 'published', - lexical: buildLexicalParagraph('Hello from React'), - updated_at: '2026-01-01T00:01:00.000Z', - published_at: '2026-01-01T00:01:00.000Z', - tags: [], - }), - ], + const publishedElsewhere = post({ + id: POST_ID, + title: 'Hello from React', + slug: 'hello-from-react', + status: 'published', + lexical: buildLexicalParagraph('Hello from React'), + updated_at: '2026-01-01T00:01:00.000Z', + published_at: '2026-01-01T00:01:00.000Z', + tags: [], }); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${POST_ID}/\\?`), { posts: [publishedElsewhere] }); await appendToBody(' and more'); await expect.poll(() => saveApi.requests.length).toBe(1); + await editorReadLanded(queryClient, publishedElsewhere); - await expect.element(editorScreen.status()).toHaveTextContent('Published'); + // The server holds their version now, so a save on any other token collides. + const collidingSaveApi = fakeAdminEndpoint( + 'PUT', + new RegExp(`^/posts/${POST_ID}/\\?`), + ({ body }) => + (body as { posts: Array<{ updated_at?: string }> }).posts[0].updated_at === + publishedElsewhere.updated_at + ? { posts: [publishedElsewhere] } + : Response.json( + { + errors: [ + { + code: 'UPDATE_COLLISION', + type: 'UpdateCollisionError', + message: 'Saving failed! Someone else is editing this post.', + }, + ], + }, + { status: 409 }, + ), + ); + await appendToBody(' again'); + + await expect.poll(() => collidingSaveApi.requests.length).toBe(1); + expect(submittedPost(collidingSaveApi)).toMatchObject({ + status: 'draft', + updated_at: '2026-01-01T00:00:01.000Z', + }); + await expect + .element(editorScreen.conflictBanner()) + .toHaveTextContent('Someone else is editing this post'); + await expect.element(editorScreen.body()).toHaveTextContent('Hello from React and more again'); }); it('keeps the editor and what was typed when the read after a save fails with a 500', async () => { diff --git a/apps/admin/src/editor/fullscreen-dialog.tsx b/apps/admin/src/editor/fullscreen-dialog.tsx index 0bc0fd50116..d14409d761f 100644 --- a/apps/admin/src/editor/fullscreen-dialog.tsx +++ b/apps/admin/src/editor/fullscreen-dialog.tsx @@ -23,6 +23,8 @@ export interface FullscreenDialogProps extends Omit modal?: boolean; title: ReactNode; layout?: keyof typeof LAYOUTS; + /** Skip transitions when moving between fullscreen editor surfaces. */ + animate?: boolean; /** Controls rendered in the header row beside the title. */ headerActions?: ReactNode; /** View controls centered between the title and actions on wide screens. */ @@ -39,6 +41,7 @@ export function FullscreenDialog({ modal = true, title, layout = 'plain', + animate = true, headerActions, headerControls, className, @@ -76,7 +79,12 @@ export function FullscreenDialog({ return ( event.preventDefault()} diff --git a/apps/admin/src/editor/preview/post-preview-modal.tsx b/apps/admin/src/editor/preview/post-preview-modal.tsx index dccabc5c3b8..ddfa367bb7f 100644 --- a/apps/admin/src/editor/preview/post-preview-modal.tsx +++ b/apps/admin/src/editor/preview/post-preview-modal.tsx @@ -66,6 +66,7 @@ interface SegmentOption { export interface PostPreviewModalProps { subjectEditor?: EmailSubjectEditor; open: boolean; + animate?: boolean; postId: string; /** The post's public preview URL (`/p/:uuid/`), empty until the post has a uuid. */ previewUrl: string; @@ -85,6 +86,7 @@ export interface PostPreviewModalProps { export function PostPreviewModal({ subjectEditor, open, + animate = true, postId, previewUrl, isPost = true, @@ -314,6 +316,7 @@ export function PostPreviewModal({ return ( {onPublish ? ( - ) : null} diff --git a/apps/admin/src/editor/preview/send-test-email.tsx b/apps/admin/src/editor/preview/send-test-email.tsx index a7b9c3b631d..d085c9c5ea0 100644 --- a/apps/admin/src/editor/preview/send-test-email.tsx +++ b/apps/admin/src/editor/preview/send-test-email.tsx @@ -54,6 +54,8 @@ export function SendTestEmail({ const mailgunIsConfigured = Boolean(configData?.config.mailgunIsConfigured) || Boolean(mailgunApiKey && mailgunDomain && mailgunBaseUrl); + // Until both have loaded, an unconfigured Mailgun can't be told from one not read yet. + const mailgunStatusKnown = configData !== undefined && settingsData !== undefined; const send = async () => { const recipient = address.trim(); @@ -114,7 +116,7 @@ export function SendTestEmail({

You’ll receive this as a {audienceLabel}.

- diff --git a/apps/admin/src/editor/publish/README.md b/apps/admin/src/editor/publish/README.md index 127fa518f66..4e2a12a69f8 100644 --- a/apps/admin/src/editor/publish/README.md +++ b/apps/admin/src/editor/publish/README.md @@ -186,7 +186,7 @@ Confirming runs `onBeforePublish` (the editor's pre-save cleanup), dispatches th | `failed` (`validation`) | The validation message, in place | | `dropped`/`superseded` | The post is no longer publishable from here | -No completion closes the modal or navigates. Reaching the complete step writes the celebration handoff (`ghost-last-published-post` or `ghost-last-scheduled-post`), and calls `onCompleted` so the caller can navigate; where the user lands is the caller's decision, not this component's. +No completion closes the modal or navigates. Successful completion writes the celebration handoff (`ghost-last-published-post` or `ghost-last-scheduled-post`), and calls `onCompleted` so the caller can navigate; where the user lands is the caller's decision, not this component's. The editor sets `showCompletion={false}` to keep the current step pending until navigation unmounts it, avoiding a flash of the fallback completion screen while the destination loads. Other callers show the completion screen by default. ## Email confirmation diff --git a/apps/admin/src/editor/publish/components/complete-step.tsx b/apps/admin/src/editor/publish/components/complete-step.tsx index 3be423317b2..d29b20486af 100644 --- a/apps/admin/src/editor/publish/components/complete-step.tsx +++ b/apps/admin/src/editor/publish/components/complete-step.tsx @@ -1,6 +1,6 @@ import { Banner, Button } from '@tryghost/shade/components'; -import { Stack, Text } from '@tryghost/shade/primitives'; -import { formatNumber } from '@tryghost/shade/utils'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { formatNumber, LucideIcon } from '@tryghost/shade/utils'; import { getRecipientType } from '@tryghost/admin-x-framework/utils/recipient-filter'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; @@ -45,17 +45,20 @@ function RevertToDraft({ onRevertToDraft }: { onRevertToDraft?: () => void }) { } return ( - - Need to make a change?{' '} - - + + Need to make a change? + + + + ); } @@ -90,17 +93,17 @@ export function CompleteStep({ {note} ) : null} - + {captured.isScheduled ? ( <> - All set! Your{' '} + All set! Your{' '} {emailOnly ? 'email' : post.displayName} will be {deliveryVerb}{' '} {formatScheduledCompletion(publishedAt, timezone)}. ) : ( <> {emailOnly && unconfirmed ? null : ( - Boom. It’s out there. + Boom. It’s out there. )} {emailOnly ? ( unconfirmed ? ( @@ -121,9 +124,9 @@ export function CompleteStep({ {emailOnly ? ( - + {unconfirmed ? null : ( - + Your post {captured.isScheduled ? 'will be' : 'was'} sent to{' '} {recipientsConfirmLabel({ @@ -143,16 +146,19 @@ export function CompleteStep({ {captured.isScheduled ? : null} ) : ( - + {captured.isScheduled ? ( ) : ( - - - Back to dashboard - - + + + )} )} diff --git a/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx b/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx index bf3a8419265..d244e47d216 100644 --- a/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx +++ b/apps/admin/src/editor/publish/components/complete-with-email-error-step.tsx @@ -1,5 +1,5 @@ import { Banner, Button } from '@tryghost/shade/components'; -import { Stack, Text } from '@tryghost/shade/primitives'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; import { isPartialEmailFailure } from '@/editor/publish/email-confirmation'; import { publishEmailErrorStep, @@ -32,14 +32,14 @@ export function CompleteWithEmailErrorStep({ return ( - - Uh-oh.{' '} + + Uh-oh.{' '} {willOnlyEmail ? 'Your post has been created but the email failed to send.' : `Your ${post.displayName} has been published but the email failed to send.`} - + {emailErrorMessage} {mailgunConfigured ? null : ( <> @@ -56,8 +56,9 @@ export function CompleteWithEmailErrorStep({ ) : null} -
+
+
); } diff --git a/apps/admin/src/editor/publish/components/confirm-step.tsx b/apps/admin/src/editor/publish/components/confirm-step.tsx index 991023706ec..631dc162d29 100644 --- a/apps/admin/src/editor/publish/components/confirm-step.tsx +++ b/apps/admin/src/editor/publish/components/confirm-step.tsx @@ -1,5 +1,6 @@ import { Banner, Button } from '@tryghost/shade/components'; -import { Stack, Text } from '@tryghost/shade/primitives'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon } from '@tryghost/shade/utils'; import { getRecipientType } from '@tryghost/admin-x-framework/utils/recipient-filter'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; @@ -76,15 +77,19 @@ export function ConfirmStep({ return ( - + Ready, set, publish. - + Share it with the world. - + {state.isScheduled ? ( <> On {formatSiteDateTime(state.scheduledAt, timezone)} your @@ -123,25 +128,27 @@ export function ConfirmStep({ ) : null} - + - + ); } 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 ceccb175e62..417de9f7c73 100644 --- a/apps/admin/src/editor/publish/components/email-recipients-options.tsx +++ b/apps/admin/src/editor/publish/components/email-recipients-options.tsx @@ -120,7 +120,11 @@ export function EmailRecipientsOptions({ tiers.length > 1 ? [...tiers] .sort((a, b) => Number(b.active) - Number(a.active)) - .map((tier) => ({ segment: `tier:${tier.slug}`, name: tier.name })) + .map((tier) => ({ + segment: `tier:${tier.slug}`, + name: tier.name, + group: tier.active ? 'Active tiers' : 'Archived tiers', + })) : []; return [ @@ -128,6 +132,7 @@ export function EmailRecipientsOptions({ ...labels.map((label) => ({ segment: `label:${label.slug}`, name: label.name, + group: 'Labels', })), ]; }, [labelsData, labelsError, labelsSettled, tiersData, tiersError, tiersSettled]); diff --git a/apps/admin/src/editor/publish/components/options-step.tsx b/apps/admin/src/editor/publish/components/options-step.tsx index f0bdc028b4f..d3fa1c35b83 100644 --- a/apps/admin/src/editor/publish/components/options-step.tsx +++ b/apps/admin/src/editor/publish/components/options-step.tsx @@ -105,10 +105,14 @@ export function OptionsStep({ return ( - + Ready, set, publish. - + Share it with the world. diff --git a/apps/admin/src/editor/publish/components/post-bookmark.tsx b/apps/admin/src/editor/publish/components/post-bookmark.tsx index 6a462020bc4..71cfe5d1e6c 100644 --- a/apps/admin/src/editor/publish/components/post-bookmark.tsx +++ b/apps/admin/src/editor/publish/components/post-bookmark.tsx @@ -23,17 +23,17 @@ export function PostBookmark({ post, siteTitle }: PostBookmarkProps) { style={{ backgroundImage: `url(${post.featureImage})` }} /> ) : null} - - + + {post.title} {post.excerpt ? ( - + {post.excerpt} ) : null} {siteTitle ? ( - + {siteTitle} ) : null} diff --git a/apps/admin/src/editor/publish/components/publish-at-options.tsx b/apps/admin/src/editor/publish/components/publish-at-options.tsx index 83c1e0be354..d2cf4163614 100644 --- a/apps/admin/src/editor/publish/components/publish-at-options.tsx +++ b/apps/admin/src/editor/publish/components/publish-at-options.tsx @@ -1,6 +1,6 @@ import { useId } from 'react'; import { Label, RadioGroup, RadioGroupItem } from '@tryghost/shade/components'; -import { Inline, Stack } from '@tryghost/shade/primitives'; +import { Grid, Inline } from '@tryghost/shade/primitives'; import { publishScheduleDate, publishScheduleTime } from '@tryghost/test-data/selectors/editor'; import { DateTimePicker } from '@/editor/date-time-picker'; import type { PublishOptionsState } from '@/editor/publish/publish-options'; @@ -21,7 +21,7 @@ export function PublishAtOptions({ const id = useId(); return ( - + onToggleScheduled(value === 'schedule')} @@ -38,6 +38,7 @@ export function PublishAtOptions({ {state.isScheduled ? ( ) : null} - +
); } diff --git a/apps/admin/src/editor/publish/components/publish-setting.tsx b/apps/admin/src/editor/publish/components/publish-setting.tsx index 9d221a9adcc..2bd17b4db47 100644 --- a/apps/admin/src/editor/publish/components/publish-setting.tsx +++ b/apps/admin/src/editor/publish/components/publish-setting.tsx @@ -1,4 +1,4 @@ -import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { Box, Inline, Stack, Text } from '@tryghost/shade/primitives'; import { LucideIcon, cn } from '@tryghost/shade/utils'; import type { ReactNode } from 'react'; @@ -37,7 +37,7 @@ export function PublishSetting({ - {open && children ?
{children}
: null} + {open && children ? ( + + {children} + + ) : null} {footer} ); diff --git a/apps/admin/src/editor/publish/components/recipient-select.tsx b/apps/admin/src/editor/publish/components/recipient-select.tsx index 9ee11f471dc..e4586388653 100644 --- a/apps/admin/src/editor/publish/components/recipient-select.tsx +++ b/apps/admin/src/editor/publish/components/recipient-select.tsx @@ -10,6 +10,7 @@ import { formatNumber } from '@tryghost/shade/utils'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { useId, useState } from 'react'; +import { LabelPicker } from '@/members/api'; import { publishRecipientFree, publishRecipientPaid, @@ -21,6 +22,7 @@ export interface SegmentOption { /** The NQL segment, e.g. `tier:gold` or `label:vip`. */ segment: string; name: string; + group: string; } export interface RecipientSelectProps { @@ -40,7 +42,7 @@ function SegmentCount({ filter }: { filter: string | null }) { } return ( - + ({formatNumber(count)}) ); @@ -164,16 +166,29 @@ export function RecipientSelect({ Selection - {segmentOptions.map((option) => ( - - toggleSegment(option.segment)} - /> - - - ))} + ({ + id: option.segment, + slug: option.segment, + name: option.name, + group: option.group, + }))} + optionSource={{ + options: segmentOptions.map((option) => ({ + value: option.segment, + label: option.name, + })), + isInitialLoad: false, + isSearching: false, + isLoadingMore: false, + hasMore: false, + loadMore: () => {}, + shouldClientFilter: true, + }} + placeholder="Search labels and tiers..." + selectedSlugs={segments.specific} + onToggle={toggleSegment} + /> ) : null} diff --git a/apps/admin/src/editor/publish/publish-flow-modal.tsx b/apps/admin/src/editor/publish/publish-flow-modal.tsx index 771e3091f5d..d45fef8ec11 100644 --- a/apps/admin/src/editor/publish/publish-flow-modal.tsx +++ b/apps/admin/src/editor/publish/publish-flow-modal.tsx @@ -1,5 +1,6 @@ import { Button } from '@tryghost/shade/components'; -import { Inline, Stack } from '@tryghost/shade/primitives'; +import { Box, Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { PageHeader } from '@tryghost/shade/patterns'; import { formatNumber } from '@tryghost/shade/utils'; import { useState } from 'react'; import { @@ -22,6 +23,9 @@ import type { PublishLimitPorts, PublishSiteInput, PublishUserInput } from './pu export interface PublishFlowModalProps { post: PublishFlowPost; + animate?: boolean; + /** Disable when onCompleted navigates, keeping the pending step visible until it unmounts. */ + showCompletion?: boolean; site: PublishSiteInput; user: PublishUserInput; limits?: PublishLimitPorts; @@ -49,6 +53,8 @@ export function PublishFlowModal({ post, ...props }: PublishFlowModalProps) { /** A post change is a new journey; no gate, failure, or completion state carries across it. */ function KeyedPublishFlowModal({ post, + animate = true, + showCompletion, site, user, limits, @@ -101,10 +107,12 @@ function KeyedPublishFlowModal({ return ( !open && close()} > - - - {step === 'complete' ? null : ( - <> - - {flow.emailErrorMessage || !onPreview ? null : ( - - )} - - )} + {flow.emailErrorMessage || !onPreview ? null : ( + + )} + + )} + - {step === 'email-error' && flow.emailErrorMessage ? ( - void flow.retryEmail()} - /> - ) : step === 'complete' ? ( - - ) : step === 'confirm' ? ( - void flow.confirmPublish()} - /> - ) : ( - - )} - + + {step === 'email-error' && flow.emailErrorMessage ? ( + void flow.retryEmail()} + /> + ) : step === 'complete' ? ( + + ) : step === 'confirm' ? ( + void flow.confirmPublish()} + /> + ) : ( + + )} + + ); } diff --git a/apps/admin/src/editor/publish/publish-flow.component.test.tsx b/apps/admin/src/editor/publish/publish-flow.component.test.tsx index 5b78421e285..b2657513076 100644 --- a/apps/admin/src/editor/publish/publish-flow.component.test.tsx +++ b/apps/admin/src/editor/publish/publish-flow.component.test.tsx @@ -1,9 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { page } from 'vitest/browser'; +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; import { render } from 'vitest-browser-react'; import { InAppProviders, fakeAdminEndpoint, fakeLabels, fakeTiers } from '@test-utils/acceptance'; -import { publishRecipientFree } from '@tryghost/test-data/selectors/editor'; +import { + publishRecipientFree, + publishRecipientSegments, + publishSettingEmailRecipients, +} from '@tryghost/test-data/selectors/editor'; import { PublishFlowModal } from '@/editor/publish/publish-flow-modal'; import { UpdateFlowModal } from '@/editor/publish/update-flow-modal'; @@ -186,6 +190,23 @@ describe('Publish flow', () => { }); }); + it('keeps confirmation pending during navigation without showing completion', async () => { + const { onCompleted } = await renderPublishFlow({ showCompletion: false }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.poll(() => onCompleted.mock.calls.length).toBe(1); + // The caller has started navigation, but the destination may still be loading. + await expect.element(publishScreen.confirm()).toBeVisible(); + await expect.element(publishScreen.confirmButton()).toBeDisabled(); + await expect(publishScreen.complete()).toHaveCount(0); + expect(JSON.parse(localStorage.getItem('ghost-last-published-post') ?? 'null')).toEqual({ + id: POST_ID, + type: 'post', + }); + }); + it('holds the confirm button through the email poll so the publish cannot be dispatched twice', async () => { const email = { status: 'pending' }; fakeEmailPolling(email); @@ -323,6 +344,50 @@ describe('Publish flow', () => { expect(localStorage.getItem('ghost-last-published-post')).toBeNull(); }); + it('rotates disclosure chevrons only 180 degrees alongside legacy Admin CSS', async () => { + const legacyStyle = document.createElement('style'); + legacyStyle.textContent = '.rotate-180 { transform: rotate(180deg); }'; + document.head.appendChild(legacyStyle); + onTestFinished(() => legacyStyle.remove()); + await renderPublishFlow(); + + const trigger = publishScreen.setting('publish-type'); + const chevron = () => trigger.element().querySelector(':scope > svg')!; + expect(getComputedStyle(chevron()).rotate).toBe('none'); + + await trigger.click(); + await expect.poll(() => getComputedStyle(chevron()).rotate).toBe('180deg'); + expect(getComputedStyle(chevron()).transform).toBe('none'); + + await trigger.click(); + await expect.poll(() => getComputedStyle(chevron()).rotate).toBe('none'); + expect(getComputedStyle(chevron()).transform).toBe('none'); + }); + + it('keeps timing radios in place when scheduling fields appear and disappear', async () => { + await renderPublishFlow(); + await publishScreen.setting('publish-at').click(); + + const now = page.getByRole('radio', { name: 'Set it live now' }); + const schedule = page.getByRole('radio', { name: 'Schedule for later' }); + await expect.element(now).toBeVisible(); + + const radioSpacing = () => { + const first = now.element().getBoundingClientRect(); + const second = schedule.element().getBoundingClientRect(); + return { x: second.x - first.x, y: second.y - first.y }; + }; + const before = radioSpacing(); + + await schedule.click(); + await expect.element(publishScreen.scheduleDate()).toBeVisible(); + expect(radioSpacing()).toEqual(before); + + await now.click(); + await expect.element(publishScreen.scheduleDate()).not.toBeInTheDocument(); + expect(radioSpacing()).toEqual(before); + }); + it('gives each publish-at radio its own id, reached from exactly one label', async () => { await renderPublishFlow(); @@ -408,6 +473,63 @@ describe('Publish flow', () => { await expect.element(publishScreen.options()).toBeInTheDocument(); }); + it('keeps recipient section height stable while a new newsletter count loads', async () => { + let finishCounts = () => {}; + const countsPending = new Promise((resolve) => { + finishCounts = resolve; + }); + fakeAdminEndpoint('GET', /^\/members\/\?.*filter=/, async ({ url }) => { + const isMonthly = new URL(url).searchParams.get('filter')?.includes('monthly'); + if (isMonthly) { + await countsPending; + } + return { + members: [], + meta: { + pagination: { + page: 1, + limit: 1, + pages: 1, + total: isMonthly ? 5 : 20, + next: null, + prev: null, + }, + }, + }; + }); + await renderPublishFlow({ + site: { + ...SITE, + newsletters: [ + ...SITE.newsletters, + { + slug: 'monthly', + name: 'Monthly', + status: 'active', + visibility: 'members', + sortOrder: 1, + }, + ], + }, + }); + await publishScreen.setting('email-recipients').click(); + await expect.element(publishScreen.recipientFree()).toHaveAccessibleName('Free (20)'); + const section = page.getByTestId(publishSettingEmailRecipients); + const before = section.element().getBoundingClientRect().height; + + try { + await page.getByRole('combobox', { name: 'Newsletter' }).click(); + await page.getByRole('option', { name: 'Monthly', exact: true }).click(); + await expect.element(publishScreen.recipientFree()).toHaveAccessibleName('Free'); + expect(section.element().getBoundingClientRect().height).toBe(before); + } finally { + finishCounts(); + } + + await expect.element(publishScreen.recipientFree()).toHaveAccessibleName('Free (5)'); + expect(section.element().getBoundingClientRect().height).toBe(before); + }); + it('loads every page before exposing tier and label recipients', async () => { const pagination = (pageNumber: number) => ({ page: pageNumber, @@ -448,14 +570,165 @@ describe('Publish flow', () => { await expect.poll(() => labelsApi.requests.length).toBe(2); await expect.element(page.getByLabelText('Specific people')).toBeInTheDocument(); await page.getByLabelText('Specific people').click(); - await expect.element(page.getByLabelText('First tier')).toBeInTheDocument(); - await expect.element(page.getByLabelText('Last tier')).toBeInTheDocument(); - await expect.element(page.getByLabelText('First label')).toBeInTheDocument(); - await expect.element(page.getByLabelText('Last label')).toBeInTheDocument(); + await page.getByPlaceholder('Search labels and tiers...').click(); + await expect.element(page.getByRole('option', { name: 'First tier' })).toBeInTheDocument(); + await expect.element(page.getByRole('option', { name: 'Last tier' })).toBeInTheDocument(); + await expect.element(page.getByRole('option', { name: 'First label' })).toBeInTheDocument(); + await expect.element(page.getByRole('option', { name: 'Last label' })).toBeInTheDocument(); expect(new URL(tiersApi.requests[1].url).searchParams.get('page')).toBe('2'); expect(new URL(labelsApi.requests[1].url).searchParams.get('page')).toBe('2'); }); + it('groups specific recipients into active tiers, archived tiers, and labels', async () => { + fakeAdminEndpoint('GET', /^\/tiers\/\?/, { + tiers: [ + { slug: 'legacy', name: 'Legacy tier', active: false }, + { slug: 'supporter', name: 'Supporter', active: true }, + ], + }); + fakeAdminEndpoint('GET', /^\/labels\/\?/, { + labels: [{ slug: 'vip', name: 'VIP' }], + }); + const { dispatch } = await renderPublishFlow({ + site: { + ...SITE, + editorDefaultEmailRecipients: 'filter', + editorDefaultEmailRecipientsFilter: 'status:free', + }, + }); + + await publishScreen.setting('email-recipients').click(); + await publishScreen.recipientFree().click(); + await page.getByLabelText('Specific people').click(); + const search = page.getByRole('combobox'); + await search.click(); + + await expect + .element( + page + .getByRole('group', { name: 'Active tiers' }) + .getByRole('option', { name: 'Supporter' }), + ) + .toBeVisible(); + await expect + .element( + page + .getByRole('group', { name: 'Archived tiers' }) + .getByRole('option', { name: 'Legacy tier' }), + ) + .toBeVisible(); + await expect + .element(page.getByRole('group', { name: 'Labels' }).getByRole('option', { name: 'VIP' })) + .toBeVisible(); + + await search.fill('Legacy'); + await expect(page.getByRole('group', { name: 'Active tiers' })).toHaveCount(0); + await expect(page.getByRole('group', { name: 'Labels' })).toHaveCount(0); + await userEvent.keyboard('{Enter}'); + await expect.element(page.getByRole('button', { name: 'Remove Legacy tier' })).toBeVisible(); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + await expect.element(publishScreen.complete()).toBeVisible(); + expect(dispatch).toHaveBeenCalledWith({ + kind: 'publish', + options: { emailOnly: false, newsletter: 'weekly', emailSegment: 'tier:legacy' }, + }); + }); + + it('searches existing recipient labels without offering label management', async () => { + fakeAdminEndpoint('GET', /^\/labels\/\?/, { + labels: [ + { slug: 'vip', name: 'VIP' }, + { slug: 'staff', name: 'Staff' }, + ], + }); + const { dispatch } = await renderPublishFlow({ + site: { + ...SITE, + editorDefaultEmailRecipients: 'filter', + editorDefaultEmailRecipientsFilter: 'status:free', + }, + }); + + await publishScreen.setting('email-recipients').click(); + await publishScreen.recipientFree().click(); + await page.getByLabelText('Specific people').click(); + const search = page.getByRole('combobox'); + await search.fill('VIP'); + await expect.element(page.getByRole('option', { name: 'VIP' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Staff' })).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Edit label/ })).toHaveCount(0); + await page.getByRole('option', { name: 'VIP' }).click(); + await search.fill('New label'); + await expect.element(page.getByText('No labels found')).toBeVisible(); + await expect(page.getByRole('option', { name: /Create/ })).toHaveCount(0); + + // Turning the segment audience off and back on retains the selected label. + await page.getByLabelText('Specific people').click(); + await expect(page.getByRole('combobox')).toHaveCount(0); + await page.getByLabelText('Specific people').click(); + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledWith({ + kind: 'publish', + options: { emailOnly: false, newsletter: 'weekly', emailSegment: 'label:vip' }, + }); + }); + + it('selects and removes specific recipients with the keyboard', async () => { + fakeAdminEndpoint('GET', /^\/labels\/\?/, { + labels: [ + { slug: 'vip', name: 'VIP' }, + { slug: 'staff', name: 'Staff' }, + ], + }); + const { dispatch } = await renderPublishFlow({ + site: { + ...SITE, + editorDefaultEmailRecipients: 'filter', + editorDefaultEmailRecipientsFilter: 'status:free', + }, + }); + + await publishScreen.setting('email-recipients').click(); + await publishScreen.recipientFree().click(); + await page.getByLabelText('Specific people').click(); + const picker = page.getByTestId(publishRecipientSegments); + const search = page.getByRole('combobox'); + await search.click(); + await expect.element(page.getByRole('option', { name: 'VIP' })).toBeVisible(); + await userEvent.keyboard('{ArrowDown}{Enter}'); + await expect.element(picker.getByRole('button', { name: 'Remove Staff' })).toBeVisible(); + await userEvent.keyboard('{Enter}'); + await expect(picker.getByRole('button', { name: 'Remove Staff' })).toHaveCount(0); + await userEvent.keyboard('{ArrowUp}{Enter}'); + await expect.element(picker.getByRole('button', { name: 'Remove VIP' })).toBeVisible(); + await userEvent.keyboard('{Backspace}'); + await expect(picker.getByRole('button', { name: 'Remove VIP' })).toHaveCount(0); + await userEvent.keyboard('{ArrowDown}{Enter}'); + await expect.element(picker.getByRole('button', { name: 'Remove Staff' })).toBeVisible(); + const activeId = search.element().getAttribute('aria-activedescendant'); + expect(activeId).toBeTruthy(); + expect(document.getElementById(activeId ?? '')).toHaveTextContent('Staff'); + await userEvent.keyboard('{Escape}'); + await expect(page.getByRole('listbox')).toHaveCount(0); + await expect.element(search).toHaveAttribute('aria-expanded', 'false'); + await expect.element(publishScreen.options()).toBeVisible(); + await userEvent.keyboard('{ArrowDown}'); + await expect.element(page.getByRole('listbox')).toBeVisible(); + await expect.element(search).toHaveAttribute('aria-expanded', 'true'); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + await expect.element(publishScreen.complete()).toBeInTheDocument(); + expect(dispatch).toHaveBeenCalledWith({ + kind: 'publish', + options: { emailOnly: false, newsletter: 'weekly', emailSegment: 'label:staff' }, + }); + }); + it('gates the flow behind the TK reminder', async () => { await renderPublishFlow({ tkCount: 2 }); @@ -774,6 +1047,24 @@ describe('Publish flow', () => { expect(retryApi.requests).toHaveLength(1); }); + it('keeps the email retry pending during navigation without showing completion', async () => { + fakeEmailPolling({ status: 'failed', error: 'Sending failed' }, { status: 'submitted' }); + const retryApi = fakeAdminEndpoint('PUT', `/emails/${EMAIL_ID}/retry/`, { emails: [] }); + const { onCompleted } = await renderPublishFlow({ showCompletion: false }); + + await publishScreen.continueButton().click(); + await publishScreen.confirmButton().click(); + + await expect.element(publishScreen.emailError()).toHaveTextContent('Sending failed'); + await publishScreen.retryEmailButton().click(); + + await expect.poll(() => onCompleted.mock.calls.length).toBe(1); + await expect.element(publishScreen.emailError()).toBeVisible(); + await expect.element(publishScreen.retryEmailButton()).toBeDisabled(); + await expect(publishScreen.complete()).toHaveCount(0); + expect(retryApi.requests).toHaveLength(1); + }); + it('keeps an expired session inside the flow instead of leaving the page', async () => { const { pathname } = window.location; // The recipient count and the email retry are the flow's own requests, diff --git a/apps/admin/src/editor/publish/update-flow-modal.tsx b/apps/admin/src/editor/publish/update-flow-modal.tsx index 47584ae94c4..c1ff6cb39b0 100644 --- a/apps/admin/src/editor/publish/update-flow-modal.tsx +++ b/apps/admin/src/editor/publish/update-flow-modal.tsx @@ -1,6 +1,7 @@ import { Banner, Button } from '@tryghost/shade/components'; -import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { Box, Inline, Stack, Text } from '@tryghost/shade/primitives'; import { formatNumber } from '@tryghost/shade/utils'; +import { PageHeader } from '@tryghost/shade/patterns'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; @@ -170,86 +171,99 @@ function KeyedUpdateFlowModal({ open onOpenChange={(open) => !open && close()} > - - - {isSent ? null : ( - - )} + + + + + {isSent ? null : ( + + )} + - - This {post.displayName} {isSent ? 'was' : 'has been'}{' '} - - {post.status} - {isSent ? ' by email' : ''} - - - - - Your {post.displayName} {isScheduled ? 'will be' : 'was'}{' '} - {hasBeenEmailed || willEmail ? ( - <> - {emailOnly ? 'sent to' : 'published and sent to'}{' '} - - {isScheduled - ? pluralSubscribers(count) - : pluralSubscribers(post.email?.email_count ?? null)} - + + + This {post.displayName} {isSent ? 'was' : 'has been'}{' '} + + {post.status} + {isSent ? ' by email' : ''} + + + + + Your {post.displayName} {isScheduled ? 'will be' : 'was'}{' '} + {hasBeenEmailed || willEmail ? ( + <> + {emailOnly ? 'sent to' : 'published and sent to'}{' '} + + {isScheduled + ? pluralSubscribers(count) + : pluralSubscribers(post.email?.email_count ?? null)} + + {showNewsletterName && post.newsletterName ? ( + <> + {' '} + of {post.newsletterName} + + ) : null} + + ) : ( + 'published on your site' + )} + {publishedAt ? <> on {formatSiteDateTime(publishedAt, timezone)}. : '.'} + + + {isScheduled && post.email ? ( + + This post was previously emailed to{' '} + {pluralSubscribers(post.email.email_count ?? null)} {showNewsletterName && post.newsletterName ? ( <> {' '} of {post.newsletterName} ) : null} - - ) : ( - 'published on your site' - )} - {publishedAt ? <> on {formatSiteDateTime(publishedAt, timezone)}. : '.'} - - - {isScheduled && post.email ? ( - - This post was previously emailed to{' '} - {pluralSubscribers(post.email.email_count ?? null)} - {showNewsletterName && post.newsletterName ? ( - <> - {' '} - of {post.newsletterName} - - ) : null} - {post.emailCreatedAt ? ( - <> on {formatSiteDateTime(post.emailCreatedAt, timezone)}. - ) : ( - '.' - )} - - ) : null} - - {failure ? ( - - {failure.message} - - ) : null} - - {isScheduled || !emailOnly ? ( -
- -
- ) : null} -
+ {post.emailCreatedAt ? ( + <> on {formatSiteDateTime(post.emailCreatedAt, timezone)}. + ) : ( + '.' + )} +
+ ) : null} + + {failure ? ( + + {failure.message} + + ) : null} + + {isScheduled || !emailOnly ? ( + + + + ) : null} +
+ ); } diff --git a/apps/admin/src/editor/publish/use-publish-flow.ts b/apps/admin/src/editor/publish/use-publish-flow.ts index a36c636a0a2..1b67c6cd2db 100644 --- a/apps/admin/src/editor/publish/use-publish-flow.ts +++ b/apps/admin/src/editor/publish/use-publish-flow.ts @@ -41,6 +41,7 @@ export interface PublishFlowOptions { /** The machine's clock, injected for tests. */ now?: () => Date; dispatch: PublishDispatcher; + showCompletion?: boolean; onBeforePublish?: () => Promise; onCompleted?: (info: { postId: string; isScheduled: boolean; hasEmail: boolean }) => void; } @@ -106,6 +107,7 @@ export function usePublishFlow({ limits, now, dispatch, + showCompletion = true, onBeforePublish, onCompleted, }: PublishFlowOptions): PublishFlow { @@ -357,18 +359,20 @@ export function usePublishFlow({ return; } completedRef.current = true; - setEmailErrorMessage(null); - setConfirmStatus('success'); - setStep('complete'); - // The server stamps the publish time; this is the closest the client has. - setCompletedAt(new Date().toISOString()); + if (showCompletion) { + setEmailErrorMessage(null); + setConfirmStatus('success'); + setStep('complete'); + // The server stamps the publish time; this is the closest the client has. + setCompletedAt(new Date().toISOString()); + } try { writePublishCelebration({ postId: post.id, displayName: post.displayName, isScheduled }); } finally { onCompleted?.({ postId: post.id, isScheduled, hasEmail }); } }, - [onCompleted, post.displayName, post.id], + [onCompleted, post.displayName, post.id, showCompletion], ); const applyEmailOutcome = useCallback( @@ -536,7 +540,9 @@ export function usePublishFlow({ if (outcome.kind !== 'submitted') { setEmailNote(EMAIL_UNCONFIRMED); } - setRetryStatus('success'); + if (showCompletion) { + setRetryStatus('success'); + } complete(false, outcome.kind !== 'not-needed'); } catch (error) { if (activeRef.current) { @@ -545,7 +551,7 @@ export function usePublishFlow({ setRetryStatus('failure'); } } - }, [complete, confirmation, post.id, refreshPostReads]); + }, [complete, confirmation, post.id, refreshPostReads, showCompletion]); return { ...optionActions, diff --git a/apps/admin/src/editor/session/README.md b/apps/admin/src/editor/session/README.md index 1e9d71c3469..1740cd6e12c 100644 --- a/apps/admin/src/editor/session/README.md +++ b/apps/admin/src/editor/session/README.md @@ -37,8 +37,9 @@ A blank title is held in the projection as the default title while the input stays empty, so a post persisted under that title does not read as permanently diverged from what the writer sees. The title input never shows it; substituting it on the way to the server is the save engine's own duty. The persisted -identity — the id and the collision token — is replaced from every -acknowledgement, so the next request carries the token the server just issued. +identity — the id and the collision token — is moved by every acknowledgement +and every reload and never by a read, so the next request carries the token of +the version the session's content was built on. The snapshot the engine reads is that projection reduced to what the queue reasons about: the id with its collision token, both `null` until the create is @@ -146,6 +147,26 @@ the request submitted and the full record the server acknowledged, so the tracker's three-way rebase has a stable base for every field the request carried. +A read is adopted only at the collision token the session holds. Core leaves the +token alone unless a column of the posts row changes, so such a read can still +carry another writer's tags, authors or tiers, or fields Core stores beside the +post: the meta and social fields and the feature image's alt text and caption. +The session takes the read as its saved copy, and the settings rules below +decide which settings fields the document adopts from it. A read at any other +token is another writer's version, or the session's own save read before its +acknowledgement landed; its content is not in the document, so the session keeps +its token and its saved copy, marks nothing dirty and starts no save. The next +save carries the token the writer's content was built on, and the server refuses +it with a collision instead of letting it overwrite the newer version; reloading +the document is the way onto that version. A refused read is offered again +whenever the engine moves on, so a read of a save that was in flight is adopted +once that save's acknowledgement has landed and the session holds its token. + +Opening the post again before the read that follows its last save has landed +starts from the cached copy that predates that save. That read then carries a +newer token than the session opened with and is refused, so the next save +collides with the writer's own earlier save and shows the conflict banner. + A save writes a title and slug the writer never typed — the request's own default title, the slug derived from the title — and the server may normalize both again. The live document adopts each, before the acknowledgement is @@ -162,12 +183,12 @@ slug behavior. Settings fields follow the same rule. A field a section does not own is carried in the projection but never sent. Successful saves and reverted edits release -ownership, so a later refetch can adopt someone else's change and an unrelated -save cannot overwrite it. An outstanding edit keeps the writer's value through a -refetch or a rejected save, and an acknowledgement adopts the server's +ownership, so a later read or acknowledgement adopts the server's value and an +unrelated save cannot overwrite it. An outstanding edit keeps the writer's value +through a refetch or a rejected save, and an acknowledgement adopts the server's normalized value only where the writer has not edited past the submitted value. -Undoing a field while its save is in flight also stays staged, even if that -save's refetch arrives before its acknowledgement: the next save persists the +Undoing a field while its save is in flight also stays staged, even if a read +at the held token carries another value meanwhile: the next save persists the undo. Ownership is decided by which fields the writer moved and when, never by comparing the live document against a pre-save snapshot, so adopting one refetch inside a save window does not stop a later one from being adopted too, and diff --git a/apps/admin/src/editor/session/editor-session.record-sync.test.ts b/apps/admin/src/editor/session/editor-session.record-sync.test.ts index 4f45c08e90c..ee930a3b502 100644 --- a/apps/admin/src/editor/session/editor-session.record-sync.test.ts +++ b/apps/admin/src/editor/session/editor-session.record-sync.test.ts @@ -7,6 +7,9 @@ import { sessionHarness, updateCollision, } from '@/editor/session/__test-utils__/session-harness'; +import type { EditorRecord } from './projection'; + +const THEIR_SAVE_AT = '2026-01-02T00:00:00.000Z'; describe('createEditorSession', () => { it('adopts a refetched record without re-baselining', () => { @@ -15,7 +18,7 @@ describe('createEditorSession', () => { session.setBaseline(record().lexical); session.patchLexical(edited); - const accepted = session.recordRefetched(record({ updated_at: '2026-01-02T00:00:00.000Z' })); + const accepted = session.recordRefetched(record()); expect(accepted).toBe(true); expect(session.getSaveSnapshot().isDirty).toBe(true); @@ -25,7 +28,7 @@ describe('createEditorSession', () => { const { session } = sessionHarness({ record: record({ title: 'Hello' }) }); session.setBaseline(record().lexical); - session.recordRefetched(record({ title: 'Hello ', updated_at: '2026-01-02T00:00:00.000Z' })); + session.recordRefetched(record({ title: 'Hello ' })); expect(session.getSaveSnapshot()).toMatchObject({ title: 'Hello', @@ -34,6 +37,122 @@ describe('createEditorSession', () => { }); }); + describe('a refetch of a version this session did not load', () => { + const theirs = () => + record({ + title: 'Their title', + lexical: buildLexicalParagraph('Their words'), + custom_excerpt: 'Their summary', + status: 'published', + published_at: THEIR_SAVE_AT, + updated_at: THEIR_SAVE_AT, + }); + + it('keeps the loaded token and saved copy under unsaved work, so the next save collides', async () => { + const { session, state } = sessionHarness( + { record: record(), baseline: record().lexical }, + { failUpdateWith: updateCollision() }, + ); + session.patchTitle('My title'); + session.patchLexical(body('My words')); + + expect(session.recordRefetched(theirs())).toBe(false); + expect(session.getSaveSnapshot()).toMatchObject({ + updatedAt: LOADED_AT, + status: 'draft', + title: 'My title', + isDirty: true, + }); + expect(session.getFields().custom_excerpt).toBeNull(); + + await session.dispatchExplicit(); + + expect(state.updates[0].payload).toMatchObject({ + updated_at: LOADED_AT, + title: 'My title', + status: 'draft', + }); + expect(session.getState().kind).toBe('conflict'); + expect(session.getFields().title).toBe('My title'); + expect(session.getLiveLexical()).toBe(JSON.stringify(body('My words'))); + }); + + it('leaves a clean session clean and unsaved', async () => { + const { session, state } = sessionHarness({ record: record(), baseline: record().lexical }); + const before = session.getView(); + + expect(session.recordRefetched(theirs())).toBe(false); + await Promise.resolve(); + + expect(session.getView()).toBe(before); + expect(session.getSaveSnapshot()).toMatchObject({ + updatedAt: LOADED_AT, + status: 'draft', + isDirty: false, + }); + expect(session.getView().pendingSave).toBeNull(); + expect(state.updates).toHaveLength(0); + expect(await session.leaveRequested()).toBe('proceed'); + }); + + it('sends the loaded token when the first edit after it saves', async () => { + const { session, state } = sessionHarness({ record: record(), baseline: record().lexical }); + + session.recordRefetched(theirs()); + session.patchLexical(body('My words')); + await session.dispatchExplicit(); + + expect(state.updates[0].payload.updated_at).toBe(LOADED_AT); + }); + + it('sends the token of its own last save rather than the newer one', async () => { + const { session, state } = sessionHarness({ record: record(), baseline: record().lexical }); + session.patchLexical(body('Saved words')); + await session.dispatchExplicit(); + const ownSave = state.acknowledged.updated_at; + + expect(session.recordRefetched(theirs())).toBe(false); + session.patchLexical(body('More words')); + await session.dispatchExplicit(); + + expect(state.updates[1].payload.updated_at).toBe(ownSave); + }); + + it('keeps the saved copy it loaded', () => { + const { session } = sessionHarness({ record: record(), baseline: record().lexical }); + session.patchTitle('My title'); + + session.recordRefetched(theirs()); + session.patchTitle('Hello'); + + expect(session.isDirty()).toBe(false); + }); + + it('recognizes a read of its own save once that save has landed', async () => { + let ownSave: EditorRecord | undefined; + let adoptedInFlight: boolean | undefined; + const built = sessionHarness( + { record: record(), baseline: record().lexical }, + { + acknowledge: (acknowledged) => { + ownSave = acknowledged; + // The read reached the server after the write, and this tab before its answer. + adoptedInFlight = built.session.recordRefetched(acknowledged); + return acknowledged; + }, + }, + ); + + built.session.patchLexical(body('Saved words')); + await built.session.dispatchExplicit(); + + expect(adoptedInFlight).toBe(false); + expect(built.session.getSaveSnapshot().updatedAt).toBe(ownSave?.updated_at); + expect(built.session.recordRefetched(ownSave!)).toBe(true); + expect(built.session.isDirty()).toBe(false); + }); + }); + it.each([ ['has no collision token', null], ['has a malformed collision token', 'not-a-date'], @@ -54,13 +173,36 @@ describe('createEditorSession', () => { }); }); + it('ignores a refetched record while it holds no collision token', () => { + const { session } = sessionHarness({ record: record({ updated_at: null }) }); + + const accepted = session.recordRefetched(record({ status: 'published' })); + + expect(accepted).toBe(false); + expect(session.getSaveSnapshot()).toMatchObject({ status: 'draft', updatedAt: '' }); + }); + + it('adopts a read of the held instant written another way, keeping the token as held', async () => { + const { session, state } = sessionHarness({ record: record(), baseline: record().lexical }); + + const accepted = session.recordRefetched( + record({ status: 'published', updated_at: '2026-01-01T00:00:00Z' }), + ); + session.patchLexical(body('Edited')); + await session.dispatchExplicit(); + + expect(accepted).toBe(true); + expect(state.updates[0].payload).toMatchObject({ + status: 'published', + updated_at: LOADED_AT, + }); + }); + it('ignores a refetch that lands after the session was disposed', () => { const { session } = sessionHarness({ record: record() }); session.dispose(); - const accepted = session.recordRefetched( - record({ status: 'published', updated_at: '2026-01-02T00:00:00.000Z' }), - ); + const accepted = session.recordRefetched(record({ status: 'published' })); expect(accepted).toBe(false); expect(session.getSaveSnapshot()).toMatchObject({ @@ -210,7 +352,7 @@ describe('createEditorSession', () => { expect(session.getLiveLexical()).toBe(JSON.stringify(body('Mine'))); }); - it('accepts a reload at a token a refetch learned after the rejected save', async () => { + it('accepts a reload at the token of a refetch it refused after the rejected save', async () => { const { session } = sessionHarness({ record: record() }, { failUpdateWith: updateCollision() }); const newer = record({ title: 'Their title', @@ -220,7 +362,8 @@ describe('createEditorSession', () => { session.patchLexical(body('Mine')); await session.dispatchExplicit(); - expect(session.recordRefetched(newer)).toBe(true); + expect(session.recordRefetched(newer)).toBe(false); + expect(session.getState().kind).toBe('conflict'); expect(session.recordReloaded(newer)).toBe(true); expect(session.getState().kind).toBe('idle'); diff --git a/apps/admin/src/editor/session/editor-session.settings-fields.test.ts b/apps/admin/src/editor/session/editor-session.settings-fields.test.ts index 45163ca61fc..2c2111e00c2 100644 --- a/apps/admin/src/editor/session/editor-session.settings-fields.test.ts +++ b/apps/admin/src/editor/session/editor-session.settings-fields.test.ts @@ -3,6 +3,7 @@ import { capturedPorts, dispatchedIntents } from '@/editor/session/__test-utils_ import { body, record, + serializedFields, sessionHarness, updateCollision, } from '@/editor/session/__test-utils__/session-harness'; @@ -68,18 +69,14 @@ describe('createEditorSession', () => { expect(session.isDirty()).toBe(false); }); - it('adopts remote settings after the local edit was saved without resending it', async () => { + it('adopts the server’s settings after the local edit was saved without resending it', async () => { const { session, state } = sessionHarness({ record: record({ featured: false }) }); session.patchFields({ featured: true }); await session.dispatchExplicit(); expect(session.isDirty()).toBe(false); - state.acknowledged = { - ...state.acknowledged, - featured: false, - updated_at: '2026-01-01T00:00:01.500Z', - }; + state.acknowledged = { ...state.acknowledged, featured: false }; session.recordRefetched(state.acknowledged); expect(session.getFields().featured).toBe(false); @@ -96,7 +93,7 @@ describe('createEditorSession', () => { session.patchFields({ featured: true }); session.patchFields({ featured: false }); - session.recordRefetched(record({ featured: true, updated_at: '2026-01-02T00:00:00.000Z' })); + session.recordRefetched(record({ featured: true })); expect(session.getFields().featured).toBe(true); expect(session.isDirty()).toBe(false); @@ -107,10 +104,7 @@ describe('createEditorSession', () => { session.patchExcerpt('Temporary'); session.patchExcerpt('Original'); - state.acknowledged = record({ - custom_excerpt: 'Remote', - updated_at: '2026-01-01T00:00:00.500Z', - }); + state.acknowledged = record({ custom_excerpt: 'Remote' }); session.recordRefetched(state.acknowledged); expect(session.getFields().custom_excerpt).toBe('Remote'); @@ -131,12 +125,7 @@ describe('createEditorSession', () => { { record: record({ tags: [{ id: 'tag1', name: 'News' }] }) }, { duringSave: () => { - built.session.recordRefetched( - record({ - tags: [{ id: 'tag3', name: 'Notice' }], - updated_at: '2026-01-01T00:00:01.000Z', - }), - ); + built.session.recordRefetched(record({ tags: [{ id: 'tag3', name: 'Notice' }] })); }, }, ); @@ -168,29 +157,28 @@ describe('createEditorSession', () => { session.patchFields({ authors: [{ id: 'author-2' }] }); session.patchFields({ authors: [{ id: 'author-1' }] }); - session.recordRefetched( - record({ authors: [{ id: 'author-3' }], updated_at: '2026-01-02T00:00:00.000Z' }), - ); + session.recordRefetched(record({ authors: [{ id: 'author-3' }] })); expect(session.getFields().authors).toEqual([{ id: 'author-3' }]); expect(session.isDirty()).toBe(false); }); it('keeps an undo made during a save even when its refetch arrives before the acknowledgement', async () => { + const ownSave = record({ featured: true, updated_at: '2026-01-01T00:00:01.000Z' }); const built = sessionHarness( { record: record({ featured: false }) }, { duringSave: () => { built.session.patchFields({ featured: false }); - built.session.recordRefetched( - record({ featured: true, updated_at: '2026-01-01T00:00:01.000Z' }), - ); + built.session.recordRefetched(ownSave); }, }, ); built.session.patchFields({ featured: true }); await built.session.dispatchExplicit(); + // The screen offers the read again once the save has landed. + expect(built.session.recordRefetched(ownSave)).toBe(true); expect(built.session.getFields().featured).toBe(false); expect(built.session.isDirty()).toBe(true); @@ -212,17 +200,36 @@ describe('createEditorSession', () => { }; }; + it('keeps an undo made during a save from another writer’s value in a read at the held version', async () => { + const built = sessionHarness( + { record: record({ meta_title: 'Loaded' }) }, + { + applied: serializedFields, + duringSave: once(() => { + built.session.patchFields({ meta_title: 'Loaded' }); + // Core stores the meta fields beside the post, so their edit leaves the token. + built.session.recordRefetched(record({ meta_title: 'Theirs' })); + }), + }, + ); + + built.session.patchFields({ meta_title: 'Mine' }); + await built.session.dispatchExplicit(); + + expect(built.session.getFields().meta_title).toBe('Loaded'); + expect(built.session.isDirty()).toBe(true); + + await built.session.dispatchExplicit(); + expect(built.state.updates[1].payload).toMatchObject({ meta_title: 'Loaded' }); + }); + it('adopts the last of two refetches that arrive inside one save', async () => { const built = sessionHarness( { record: record({ visibility: 'public' }) }, { duringSave: once(() => { - built.session.recordRefetched( - record({ visibility: 'members', updated_at: '2026-01-01T00:00:00.500Z' }), - ); - built.session.recordRefetched( - record({ visibility: 'paid', updated_at: '2026-01-01T00:00:00.700Z' }), - ); + built.session.recordRefetched(record({ visibility: 'members' })); + built.session.recordRefetched(record({ visibility: 'paid' })); }), acknowledge: (acknowledged) => ({ ...acknowledged, visibility: 'paid' }), }, @@ -258,12 +265,8 @@ describe('createEditorSession', () => { { record: record({ [field]: [] }) }, { duringSave: once(() => { - built.session.recordRefetched( - record({ [field]: first, updated_at: '2026-01-01T00:00:00.500Z' }), - ); - built.session.recordRefetched( - record({ [field]: second, updated_at: '2026-01-01T00:00:00.700Z' }), - ); + built.session.recordRefetched(record({ [field]: first })); + built.session.recordRefetched(record({ [field]: second })); }), acknowledge: (acknowledged) => ({ ...acknowledged, [field]: second }), }, @@ -286,11 +289,7 @@ describe('createEditorSession', () => { const built = sessionHarness( { record: record({ visibility: 'public' }) }, { - duringSave: once(() => - built.session.recordRefetched( - record({ visibility: 'paid', updated_at: '2026-01-01T00:00:00.500Z' }), - ), - ), + duringSave: once(() => built.session.recordRefetched(record({ visibility: 'paid' }))), acknowledge: (acknowledged) => ({ ...acknowledged, visibility: 'members' }), }, ); @@ -307,9 +306,7 @@ describe('createEditorSession', () => { { record: record({ visibility: 'public' }) }, { duringSave: once(() => { - built.session.recordRefetched( - record({ visibility: 'paid', updated_at: '2026-01-01T00:00:00.500Z' }), - ); + built.session.recordRefetched(record({ visibility: 'paid' })); built.session.patchFields({ visibility: 'members' }); }), acknowledge: (acknowledged) => ({ ...acknowledged, visibility: 'paid' }), @@ -348,9 +345,7 @@ describe('createEditorSession', () => { { duringSave: once(() => { built.session.patchFields({ [field]: edited }); - built.session.recordRefetched( - record({ [field]: edited, updated_at: '2026-01-01T00:00:00.500Z' }), - ); + built.session.recordRefetched(record({ [field]: edited })); }), acknowledge: (acknowledged, count) => ({ ...acknowledged, @@ -400,9 +395,7 @@ describe('createEditorSession', () => { { duringSave: once(() => { built.session.patchFields({ visibility: 'public' }); - built.session.recordRefetched( - record({ visibility: 'paid', updated_at: '2026-01-01T00:00:00.500Z' }), - ); + built.session.recordRefetched(record({ visibility: 'paid' })); }), acknowledge: (acknowledged) => ({ ...acknowledged, visibility: 'paid' }), }, @@ -427,12 +420,7 @@ describe('createEditorSession', () => { { duringSave: once(() => { built.session.patchFields({ tags: loaded.map((tag) => ({ ...tag })) }); - built.session.recordRefetched( - record({ - tags: [{ id: 'tag-2', name: 'Tech' }], - updated_at: '2026-01-01T00:00:00.500Z', - }), - ); + built.session.recordRefetched(record({ tags: [{ id: 'tag-2', name: 'Tech' }] })); }), acknowledge: (acknowledged) => ({ ...acknowledged, @@ -610,12 +598,24 @@ describe('createEditorSession', () => { record: record({ visibility: 'public', featured: false }), }); + const accepted = session.recordRefetched(record({ visibility: 'paid', featured: true })); + + expect(accepted).toBe(true); + expect(session.getFields()).toMatchObject({ visibility: 'paid', featured: true }); + expect(session.isDirty()).toBe(false); + }); + + it('adopts no settings from a refetch of a version it did not load', () => { + const { session } = sessionHarness({ + record: record({ visibility: 'public', featured: false }), + }); + const accepted = session.recordRefetched( record({ visibility: 'paid', featured: true, updated_at: '2026-01-02T00:00:00.000Z' }), ); - expect(accepted).toBe(true); - expect(session.getFields()).toMatchObject({ visibility: 'paid', featured: true }); + expect(accepted).toBe(false); + expect(session.getFields()).toMatchObject({ visibility: 'public', featured: false }); expect(session.isDirty()).toBe(false); }); @@ -625,9 +625,7 @@ describe('createEditorSession', () => { }); session.patchFields({ featured: true }); - session.recordRefetched( - record({ visibility: 'paid', featured: false, updated_at: '2026-01-02T00:00:00.000Z' }), - ); + session.recordRefetched(record({ visibility: 'paid', featured: false })); expect(session.getFields()).toMatchObject({ visibility: 'paid', featured: true }); expect(session.isDirty()).toBe(true); @@ -637,9 +635,7 @@ describe('createEditorSession', () => { const { session } = sessionHarness({ record: record({ custom_excerpt: 'Opened with' }) }); session.patchExcerpt('typing…'); - session.recordRefetched( - record({ custom_excerpt: 'From elsewhere', updated_at: '2026-01-02T00:00:00.000Z' }), - ); + session.recordRefetched(record({ custom_excerpt: 'From the server' })); expect(session.getFields().custom_excerpt).toBe('typing…'); }); @@ -730,9 +726,7 @@ describe('createEditorSession', () => { duringSave: once(() => { built.session.patchFields({ authors: [AUTHORS[0]] }); // The server has not seen the removal yet, so its copy still has both. - built.session.recordRefetched( - record({ authors: AUTHORS, updated_at: '2026-01-01T00:00:00.500Z' }), - ); + built.session.recordRefetched(record({ authors: AUTHORS })); }), acknowledge: (acknowledged) => ({ ...acknowledged, authors: AUTHORS }), }, diff --git a/apps/admin/src/editor/session/editor-session.ts b/apps/admin/src/editor/session/editor-session.ts index 594b28657ee..a24bc60e99e 100644 --- a/apps/admin/src/editor/session/editor-session.ts +++ b/apps/admin/src/editor/session/editor-session.ts @@ -264,6 +264,15 @@ export function isOlderToken(candidate: string, held: string | null): boolean { return !Number.isNaN(candidateTime) && !Number.isNaN(heldTime) && candidateTime < heldTime; } +/** Whether a record's collision token names the version already held. */ +function isHeldToken(candidate: string | null | undefined, held: string | null): boolean { + return ( + isCollisionToken(candidate) && + isCollisionToken(held) && + Date.parse(candidate) === Date.parse(held) + ); +} + /** * Composes the change tracker, slug machine and save engine into one editing * session: one per opened post, never shared between two new posts. @@ -928,12 +937,12 @@ export function createEditorSession({ getLiveLexical: () => live.lexical, recordRefetched: (next) => { - const updatedAt = next.updated_at ?? ''; + // Another version's content is not in this document: holding its token + // would let the next save overwrite it instead of colliding. if ( disposed || identity.id !== next.id || - !isCollisionToken(updatedAt) || - isOlderToken(updatedAt, identity.updatedAt) + !isHeldToken(next.updated_at, identity.updatedAt) ) { return false; } @@ -942,7 +951,6 @@ export function createEditorSession({ const projection = projectionOf(next); tracker.setSaved(next.id, projection); adoptSettings(projection, (key) => adoptable.has(key)); - identity = { id: next.id, updatedAt }; status = next.status ?? status; publishedAt = next.published_at ?? null; releaseSavedPublishTime(); diff --git a/apps/admin/src/editor/session/editor-session.view.test.ts b/apps/admin/src/editor/session/editor-session.view.test.ts index e4e10b59e17..c58f8cc3261 100644 --- a/apps/admin/src/editor/session/editor-session.view.test.ts +++ b/apps/admin/src/editor/session/editor-session.view.test.ts @@ -76,9 +76,8 @@ describe('createEditorSession', () => { session.recordRefetched( record({ status: 'published', - custom_excerpt: 'Another editor’s summary', + custom_excerpt: 'The server’s summary', published_at: '2025-12-01T10:00:00.000Z', - updated_at: '2026-01-01T00:00:01.000Z', }), ), ).toBe(true); @@ -88,7 +87,7 @@ describe('createEditorSession', () => { isDirty: true, settings: { meta_title: 'My unsaved search title', - custom_excerpt: 'Another editor’s summary', + custom_excerpt: 'The server’s summary', }, publishTime: { status: 'published', publishedAt: '2025-12-01T10:00:00.000Z' }, }); diff --git a/apps/admin/src/editor/session/publish-time-save.test.ts b/apps/admin/src/editor/session/publish-time-save.test.ts index c3fbba720c2..f6f89945a15 100644 --- a/apps/admin/src/editor/session/publish-time-save.test.ts +++ b/apps/admin/src/editor/session/publish-time-save.test.ts @@ -375,7 +375,7 @@ describe('staging the publish time', () => { slug: 'post', status: 'published', lexical: null, - updated_at: '2026-01-01T00:00:05.000Z', + updated_at: LOADED_AT, published_at: PAST, tags: [], }); @@ -396,11 +396,32 @@ describe('staging the publish time', () => { slug: 'post', status: 'published', lexical: null, - updated_at: '2026-01-01T00:00:05.000Z', + updated_at: LOADED_AT, published_at: OLDER, tags: [], }); expect(session.isDirty()).toBe(false); }); + + it('keeps a staged time through a refetch of a version it did not load', () => { + const { session } = publishTimeSession('published', PAST); + + session.editPublishedAt(OLDER); + session.recordRefetched({ + id: 'post-id', + uuid: 'post-uuid', + url: 'https://example.com/post/', + title: 'Post', + slug: 'post', + status: 'published', + lexical: null, + updated_at: '2026-01-01T00:00:05.000Z', + published_at: OLDER, + tags: [], + }); + + expect(session.getPublishedAt()).toBe(OLDER); + expect(session.isDirty()).toBe(true); + }); }); diff --git a/apps/admin/src/editor/session/use-editor-session.test.tsx b/apps/admin/src/editor/session/use-editor-session.test.tsx index 562375382f0..628b8330328 100644 --- a/apps/admin/src/editor/session/use-editor-session.test.tsx +++ b/apps/admin/src/editor/session/use-editor-session.test.tsx @@ -5,18 +5,48 @@ import type { ReactNode } from 'react'; import { dispatchedIntents } from './__test-utils__/save-engine-spy'; import { record } from './__test-utils__/session-harness'; import { reportLeaveConfirmation, reportSaveFailure } from '@/editor/report-error'; +import type { SaveCompletion } from '@/editor/engine/save-engine'; +import { deferred } from '@/utils/deferred'; import type { EditorRecord } from './projection'; import { useEditorSession } from './use-editor-session'; type SaveEngineModule = typeof import('@/editor/engine/save-engine'); +type EditorSessionModule = typeof import('./editor-session'); vi.mock('@/editor/engine/save-engine', async (importOriginal) => { const spy = await import('@/editor/session/__test-utils__/save-engine-spy'); return spy.spiedSaveEngine(await importOriginal()); }); +// The screen's read of the post and the update the session sends, driven by each spec. +const postApi = vi.hoisted(() => ({ + read: undefined as { posts: EditorRecord[] } | undefined, + edit: vi.fn(), +})); + +// Every read the screen offers the session, in order. +const offeredReads = vi.hoisted((): EditorRecord[] => []); + +vi.mock('./editor-session', async (importOriginal) => { + const actual = await importOriginal(); + const createEditorSession: EditorSessionModule['createEditorSession'] = (options) => { + const session = actual.createEditorSession(options); + return { + ...session, + recordRefetched: (read) => { + offeredReads.push(read); + return session.recordRefetched(read); + }, + }; + }; + return { ...actual, createEditorSession }; +}); + beforeEach(() => { dispatchedIntents.length = 0; + postApi.read = undefined; + postApi.edit.mockReset(); + offeredReads.length = 0; }); vi.mock('@tryghost/admin-x-framework', () => ({ @@ -47,8 +77,8 @@ vi.mock('@tryghost/admin-x-framework/api/slugs', () => ({ vi.mock('@tryghost/admin-x-framework/api/posts', () => ({ useAddPost: () => ({ mutateAsync: vi.fn() }), - useEditPost: () => ({ mutateAsync: vi.fn() }), - useEditorPost: () => ({ data: undefined }), + useEditPost: () => ({ mutateAsync: postApi.edit }), + useEditorPost: () => ({ data: postApi.read }), postsDataType: 'PostsResponseType', })); @@ -205,3 +235,62 @@ describe('useEditorSession reporting', () => { expect(postType).toBe('post'); }); }); + +describe('useEditorSession refetched record', () => { + it('offers an accepted read only once, however the engine moves on', async () => { + const loaded = record(); + postApi.read = { posts: [loaded] }; + postApi.edit.mockResolvedValue({ + posts: [record({ title: 'A new title', updated_at: '2026-01-01T00:00:01.000Z' })], + }); + const { result } = setup(loaded); + expect(offeredReads).toEqual([loaded]); + + act(() => result.current.bind.onTitleChange('A new title')); + await act(() => result.current.saveExplicit()); + + expect(postApi.edit).toHaveBeenCalledTimes(1); + expect(offeredReads).toEqual([loaded]); + }); + + it('keeps describing its own version when a read brings another writer’s', () => { + const { result, rerender } = setup(); + const loaded = result.current.loadedRecord; + + postApi.read = { + posts: [record({ title: 'Their title', updated_at: '2026-01-02T00:00:00.000Z' })], + }; + rerender(); + + expect(result.current.loadedRecord).toBe(loaded); + expect(result.current.isDirty()).toBe(false); + }); + + it('describes a read of its own save once that save has landed', async () => { + const answer = deferred<{ posts: EditorRecord[] }>(); + postApi.edit.mockReturnValueOnce(answer.promise); + const { result, rerender } = setup(); + const loaded = result.current.loadedRecord; + + act(() => result.current.bind.onTitleChange('A new title')); + let saving!: Promise; + act(() => { + saving = result.current.saveExplicit(); + }); + await waitFor(() => expect(postApi.edit).toHaveBeenCalledTimes(1)); + + // The read reached the server after the write, and this tab before its answer. + const ownSave = record({ title: 'A new title', updated_at: '2026-01-01T00:00:01.000Z' }); + postApi.read = { posts: [ownSave] }; + rerender(); + expect(result.current.loadedRecord).toBe(loaded); + + await act(async () => { + answer.resolve({ posts: [ownSave] }); + await saving; + }); + + expect(result.current.loadedRecord).toBe(ownSave); + expect(result.current.isDirty()).toBe(false); + }); +}); diff --git a/apps/admin/src/editor/session/use-editor-session.ts b/apps/admin/src/editor/session/use-editor-session.ts index 9dcf5768806..40c498ce64f 100644 --- a/apps/admin/src/editor/session/use-editor-session.ts +++ b/apps/admin/src/editor/session/use-editor-session.ts @@ -336,16 +336,18 @@ export function useEditorSession({ }); const saved = postType === 'page' ? pageQuery.data?.pages[0] : postQuery.data?.posts[0]; + // Only the version the session holds may replace what the screen describes. A + // refused read is offered again as the engine moves on: a landing save may claim it. + const acceptedRead = useRef(undefined); useEffect(() => { - if (!saved) { + if (!saved || saved === acceptedRead.current) { return; } - // The screen's query and a reload both answer with the post; only a valid, - // non-older collision token may replace what the screen describes. if (session.recordRefetched(saved)) { + acceptedRead.current = saved; setLoadedRecord(saved); } - }, [saved, session]); + }, [saved, session, state]); // Its own request: a query refetch would land before the session could refuse the copy. const reload = useCallback(async (): Promise => { diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index a116169b750..67408c1bc49 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -13,23 +13,34 @@ initial-value: 0; } -@keyframes email-sending-arrow-rise { +/* Shimmer for an active send's status line. The gradient is 300% wide so + each sweep has a pause before the next. */ +@keyframes email-sending-shimmer { from { - transform: translateY(115%); + background-position: 100% 0; } to { - transform: translateY(-115%); + background-position: 0% 0; } } -@keyframes email-sending-fill-rise { - from { - transform: translateY(100%); - } - - to { - transform: translateY(0%); +@utility email-sending-shimmer { + background-image: linear-gradient( + 90deg, + var(--chart-purple) 40%, + color-mix(in oklab, var(--chart-purple) 50%, transparent) 50%, + var(--chart-purple) 60% + ); + background-size: 300% 100%; + background-clip: text; + color: transparent; + animation: email-sending-shimmer 1.75s linear infinite; + + @media (prefers-reduced-motion: reduce) { + background-image: none; + color: var(--chart-purple); + animation: none; } } @@ -118,8 +129,6 @@ must be generated in this Tailwind lane; the font files themselves load with settings/custom-fonts.css in the settings chunk. */ @theme { - --animate-email-sending-arrow-rise: email-sending-arrow-rise 1200ms linear infinite; - --animate-email-sending-fill-rise: email-sending-fill-rise 1200ms linear infinite; --font-cardo: Cardo; --font-manrope: Manrope; --font-merriweather: Merriweather; diff --git a/apps/admin/src/members/api.ts b/apps/admin/src/members/api.ts index b70003bb1e8..6ccfe1fd695 100644 --- a/apps/admin/src/members/api.ts +++ b/apps/admin/src/members/api.ts @@ -14,3 +14,4 @@ export { normalizeSharedViewName, parseAllSharedViewsJSON, } from './shared-views'; +export { LabelPicker } from './label-picker'; diff --git a/apps/admin/src/members/label-picker/can-create-label.ts b/apps/admin/src/members/label-picker/can-create-label.ts index ee7a41792f8..be79a8946e8 100644 --- a/apps/admin/src/members/label-picker/can-create-label.ts +++ b/apps/admin/src/members/label-picker/can-create-label.ts @@ -1,6 +1,6 @@ import { type Label } from '@tryghost/admin-x-framework/api/labels'; -export function canCreateLabel(labels: Label[], query: string): boolean { +export function canCreateLabel(labels: Pick[], query: string): boolean { const normalized = query.trim().toLowerCase(); if (!normalized) { return false; diff --git a/apps/admin/src/members/label-picker/edit-row.tsx b/apps/admin/src/members/label-picker/edit-row.tsx index b9548c829d5..de29e522f23 100644 --- a/apps/admin/src/members/label-picker/edit-row.tsx +++ b/apps/admin/src/members/label-picker/edit-row.tsx @@ -6,7 +6,7 @@ import { type Label } from '@tryghost/admin-x-framework/api/labels'; import { getErrorMessage } from '@tryghost/admin-x-framework/errors'; interface EditRowProps { - label: Label; + label: Pick; onSave: (id: string, name: string) => Promise; onCancel: () => void; onDelete: (id: string) => Promise; diff --git a/apps/admin/src/members/label-picker/label-picker.tsx b/apps/admin/src/members/label-picker/label-picker.tsx index d4ad731f656..38c9c49b4de 100644 --- a/apps/admin/src/members/label-picker/label-picker.tsx +++ b/apps/admin/src/members/label-picker/label-picker.tsx @@ -6,8 +6,10 @@ import { Command, CommandEmpty, CommandGroup, + CommandInput, CommandItem, CommandList, + CommandSeparator, } from '@tryghost/shade/components'; import { useShade } from '@tryghost/shade/app'; import { EditRow } from './edit-row'; @@ -25,11 +27,16 @@ const DROPDOWN_CHROME = 6; // Kept clear of the viewport edge so the list never sits flush against it. const GUTTER = 16; +type PickerLabel = Pick & { + /** Optional heading for related options; omitted for a flat label list. */ + group?: string; +}; + export interface LabelPickerProps { - labels: Label[]; + labels: PickerLabel[]; optionSource: ComboboxOptionSource; selectedSlugs: string[]; - resolvedSelectedLabels?: Label[]; + resolvedSelectedLabels?: PickerLabel[]; onToggle: (slug: string) => void; // Creation onCreate?: (name: string) => Promise
+ ) : ( { const section = await openExportTab(); await expect.element(section.getByRole('button', { name: 'Content & settings' })).toBeVisible(); await expect.element(section.getByRole('button', { name: 'Post analytics' })).toBeVisible(); - await expect - .element(section.getByRole('button', { name: 'Export data' })) - .not.toBeInTheDocument(); + await expect.element(section.getByRole('button', { name: 'All data' })).not.toBeInTheDocument(); + }); + + it('keeps the post analytics export button alongside the export dialog', async () => { + fakeSettingsScreens(); + const csv = new TextEncoder().encode('id,title\n').buffer; + const postsExport = fakeAdminEndpoint('GET', /^\/posts\/export\//, csv, { + contentType: 'text/csv', + }); + await renderAdminApp('/settings/advanced', { labs: { selfServeArchives: true } }); + + const section = await openExportTab(); + await expect.element(section.getByRole('button', { name: 'All data' })).toBeVisible(); + await section.getByRole('button', { name: 'Post analytics' }).click(); + + await expect.poll(() => postsExport.lastRequest?.url).toContain('/posts/export/?limit=1000'); }); it('offers the sync export dialog without media and downloads the zip', async () => { @@ -58,7 +71,7 @@ describe('Migration tools export', () => { await renderAdminApp('/settings/advanced', { labs: { selfServeArchives: true } }); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await expect @@ -82,7 +95,7 @@ describe('Migration tools export', () => { await renderAdminApp('/settings/advanced', { labs: { selfServeArchives: true } }); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByRole('checkbox', { name: 'Members' }).click(); @@ -105,7 +118,7 @@ describe('Migration tools export', () => { await renderAdminApp('/settings/advanced', { labs: { selfServeArchives: true } }); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByRole('button', { name: 'Export', exact: true }).click(); @@ -124,7 +137,7 @@ describe('Migration tools export', () => { await renderWithArchiveHost(); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await expect @@ -168,7 +181,7 @@ describe('Migration tools export', () => { await renderWithArchiveHost(); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByRole('button', { name: 'Export', exact: true }).click(); @@ -190,7 +203,7 @@ describe('Migration tools export', () => { await renderWithArchiveHost(); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByText('Post analytics', { exact: true }).click(); @@ -220,7 +233,7 @@ describe('Migration tools export', () => { await renderWithArchiveHost(); const section = await openExportTab(); - await section.getByRole('button', { name: 'Export data' }).click(); + await section.getByRole('button', { name: 'All data' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByRole('button', { name: 'Export', exact: true }).click(); diff --git a/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx b/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx index a96cae5f58c..81188b03519 100644 --- a/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx @@ -34,6 +34,29 @@ const MigrationToolsExport: React.FC = () => { } }; + const postAnalyticsButton = ( + + ); + if (hasSelfServeArchives) { return ( <> @@ -46,8 +69,9 @@ const MigrationToolsExport: React.FC = () => { onClick={() => setExportAllOpen(true)} > - Export data + All data + {postAnalyticsButton} @@ -65,26 +89,7 @@ const MigrationToolsExport: React.FC = () => { Content & settings - + {postAnalyticsButton} ); }; diff --git a/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx b/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx index 45d14467f76..7dcfd96d17f 100644 --- a/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx +++ b/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx @@ -2,6 +2,9 @@ import { HostLimitError } from '@tryghost/admin-x-framework/errors'; import { useCallback } from 'react'; import { useHostLimits, useLimiter } from '@tryghost/admin-x-framework/hooks'; +/** Theme choice is limited by an allowlist alone, so the staff lists never hold the check up. */ +const THEME_LIMITS = ['customThemes'] as const; + interface UseCheckThemeLimitErrorReturn { checkThemeLimitError: (themeName?: string) => Promise; isThemeLimited: boolean; @@ -11,7 +14,7 @@ interface UseCheckThemeLimitErrorReturn { } export const useCheckThemeLimitError = (): UseCheckThemeLimitErrorReturn => { - const limiter = useLimiter(); + const limiter = useLimiter({ limits: THEME_LIMITS }); const allowedThemesList = useHostLimits()?.customThemes?.allowlist; // Single theme: always error diff --git a/apps/admin/src/settings/membership/analytics.acceptance.test.tsx b/apps/admin/src/settings/membership/analytics.acceptance.test.tsx index b4c82bfb4f4..3f24032f6d4 100644 --- a/apps/admin/src/settings/membership/analytics.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/analytics.acceptance.test.tsx @@ -8,6 +8,7 @@ import { renderAdminApp, settingsResponse, } from '@test-utils/acceptance'; +import { deferred } from '@/utils/deferred'; import { settingsScreen } from '@/settings/settings.screen'; type AnalyticsSetting = { key: string; value: boolean; is_read_only?: boolean }; @@ -172,14 +173,11 @@ describe('Analytics settings', () => { it('disables the post analytics export while downloading from the expected endpoint', async () => { fakeSettingsScreens(); - let finishExport!: () => void; + const pendingExport = deferred(); const exportApi = fakeAdminEndpoint( 'GET', '/posts/export/?limit=1000', - () => - new Promise((resolve) => { - finishExport = () => resolve({}); - }), + () => pendingExport.promise, ); await renderAdminApp('/settings'); @@ -188,11 +186,14 @@ describe('Analytics settings', () => { const button = migrationTools.getByTestId('post-analytics-export-button'); await button.click(); - expect(exportApi.requests).toHaveLength(1); - await expect.element(button).toBeDisabled(); - await expect.element(button).toHaveTextContent('Loading...'); - - finishExport(); + try { + await expect.poll(() => exportApi.requests.length).toBe(1); + await expect.element(button).toBeDisabled(); + await expect.element(button).toHaveTextContent('Loading...'); + } finally { + // Settle the held export even when an assertion fails. + pendingExport.resolve({}); + } await expect.element(button).toBeEnabled(); expect(exportApi.lastRequest?.url).toContain('/posts/export/?limit=1000'); diff --git a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx index a60121bcdcb..3131b9d39e1 100644 --- a/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/custom-fields.acceptance.test.tsx @@ -35,6 +35,11 @@ const archivedField: MemberCustomField = { const flagOn = { labs: { membersCustomFields: true } }; +// A drag that travels to the drop point, as a hand does. dnd-kit works out the row +// under the pointer as it moves, so a drag that jumps straight there in one move can +// be released before the list has registered where it is. +const DRAG = { steps: 10 }; + type CustomField = typeof companyField; function fakeCustomFields(fields: CustomField[] = [companyField]) { @@ -513,7 +518,7 @@ describe('Custom fields', () => { // lifted item by a flat 25px and it never reaches the next row. const handle = settingsScreen.customFields().getByLabelText('Reorder Nickname'); await expect.element(handle).toBeVisible(); - await userEvent.dragAndDrop(handle, rows.first()); + await userEvent.dragAndDrop(handle, rows.first(), DRAG); // The whole list goes up, in the order the drag left it, keys only — order is a // property of the list, so a field never carries a rank. Nickname was dropped on @@ -561,6 +566,7 @@ describe('Custom fields', () => { await userEvent.dragAndDrop( settingsScreen.customFields().getByLabelText('Reorder Nickname'), rows.first(), + DRAG, ); await expect.element(rows.first()).toHaveTextContent('Nickname'); @@ -609,6 +615,7 @@ describe('Custom fields', () => { await userEvent.dragAndDrop( settingsScreen.customFields().getByLabelText('Reorder Nickname'), rows.first(), + DRAG, ); // The server's own words reach the publisher, not a generic failure: they name @@ -638,6 +645,7 @@ describe('Custom fields', () => { await userEvent.dragAndDrop( settingsScreen.customFields().getByLabelText('Reorder Shirt size'), rows.first(), + DRAG, ); // The archived field is named even though it was never on screen: an order states @@ -670,6 +678,7 @@ describe('Custom fields', () => { await userEvent.dragAndDrop( settingsScreen.customFields().getByLabelText('Reorder Field 2'), rows.first(), + DRAG, ); // The two fields the publisher cannot see are still named, and still last. diff --git a/apps/admin/src/settings/site/change-theme.tsx b/apps/admin/src/settings/site/change-theme.tsx index 5182b09d7c2..921b0e4351f 100644 --- a/apps/admin/src/settings/site/change-theme.tsx +++ b/apps/admin/src/settings/site/change-theme.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import TopLevelGroup from '@/settings/components/top-level-group'; import { Button, @@ -19,8 +19,6 @@ import { useUpgradeRoute } from '@/settings/hooks/use-upgrade-route'; import { withErrorBoundary } from '@/settings/components/with-error-boundary'; const ChangeTheme: React.FC<{ keywords: string[] }> = ({ keywords }) => { - const [themeLimitError, setThemeLimitError] = useState(null); - const [isCheckingLimit, setIsCheckingLimit] = useState(false); const { checkThemeLimitError } = useCheckThemeLimitError(); const { route, updateRoute } = useSettingsNavigation(); const upgradeRoute = useUpgradeRoute(); @@ -28,31 +26,18 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({ keywords }) => { const { data: themesData } = useBrowseThemes(); const activeTheme = themesData?.themes.find((theme: Theme) => theme.active); - useEffect(() => { - const checkIfThemeChangeAllowed = async () => { - setIsCheckingLimit(true); - const error = await checkThemeLimitError(); - setThemeLimitError(error); - setIsCheckingLimit(false); - }; + const openPreviewModal = async () => { + const limitError = await checkThemeLimitError(); - void checkIfThemeChangeAllowed(); - }, [checkThemeLimitError]); - - const openPreviewModal = () => { - // Wait for limit check if still in progress - if (isCheckingLimit) { - return; - } - - if (themeLimitError) { + if (limitError) { showLimit({ - prompt: themeLimitError, + prompt: limitError, onOk: () => updateRoute({ route: upgradeRoute, isExternal: true }), }); - } else { - updateRoute('design/change-theme'); + return; } + + updateRoute('design/change-theme'); }; const openThemeEditor = async () => { @@ -129,7 +114,7 @@ const ChangeTheme: React.FC<{ keywords: string[] }> = ({ keywords }) => { size="sm" type="button" variant="ghost" - onClick={openPreviewModal} + onClick={() => void openPreviewModal()} > Change theme diff --git a/apps/admin/test-utils/acceptance/README.md b/apps/admin/test-utils/acceptance/README.md index 5bbe7e71efd..737b9062c3b 100644 --- a/apps/admin/test-utils/acceptance/README.md +++ b/apps/admin/test-utils/acceptance/README.md @@ -30,7 +30,7 @@ await expect.poll(() => document.documentElement.classList.contains("dark")).toB **Unsaved-changes guards.** Typing into a dirty screen arms its navigation guard through a chain of passive effects, so a spec that navigates straight after a `fill` races the guard and an unguarded navigation just goes through. Wait for the guard itself, never for a frame count: `await expect.poll(unsavedChangesGuarded).toBe(true)` before the click or `window.history.back()`. -**One render per test.** Each `renderAdminApp` gets a fresh QueryClient and the fake API resets between tests — there is no reload. State that would be persisted on a real server (user preferences, settings) is _represented_ by boot overrides; a journey that genuinely needs persistence across reloads belongs in `e2e/`. +**One render per test.** Each `renderAdminApp` gets a fresh QueryClient, which it resolves with for a wait nothing on screen can show, and the fake API resets between tests — there is no reload. State that would be persisted on a real server (user preferences, settings) is _represented_ by boot overrides; a journey that genuinely needs persistence across reloads belongs in `e2e/`. **Host page.** `renderAdminApp` mounts into a stand-in of the production host page (the `react-admin` body class + `#root` from index.html), so the shell's viewport-bounded grid applies and scroll-driven behaviors — virtualized lists, infinite paging — work like production. @@ -77,7 +77,7 @@ For a **browse endpoint** (`GET //`), add a resource fake in `resource - `{kind: "passthrough"}` — serves exactly the declared entities, never interprets the query. Right for NQL-filtered lists; per-request responses are declared with a function of the parsed query. - `{kind: "declared-query", covers, select}` — implements _trivial declared_ behaviors only (a field match, page/limit slicing); any filter component outside `covers` 418s instead of silently serving the full world. -For a **one-off endpoint** (stats subpaths, settings chrome, a mutation the spec asserts on), use `fakeAdminEndpoint(method, apiPath, response)` — it enters the route listing, returns a capture, and `response` may be a function of the captured request (`({body}) => body` is an honest echo). +For a **one-off endpoint** (stats subpaths, settings chrome, a mutation the spec asserts on), use `fakeAdminEndpoint(method, apiPath, response)` — it enters the route listing, returns a capture, and `response` may be a function of the captured request (`({body}) => body` is an honest echo). A `Response`, given or returned by the function, is served as it is, so a status can depend on the request. ## Faking Tinybird (web analytics) diff --git a/apps/admin/test-utils/acceptance/editor.ts b/apps/admin/test-utils/acceptance/editor.ts index c9069ed53ad..d875bc06192 100644 --- a/apps/admin/test-utils/acceptance/editor.ts +++ b/apps/admin/test-utils/acceptance/editor.ts @@ -1,3 +1,5 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { postsDataType } from '@tryghost/admin-x-framework/api/posts'; import { buildLexicalParagraph, post, settingsResponse, type Post } from '@tryghost/test-data'; import type { RenderAdminAppOptions } from './render-admin-app'; import { fakeNewsletters, fakePosts, fakeSnippets } from './resources'; @@ -45,6 +47,40 @@ export function submittedPost(capture: EndpointCapture, index = -1): Record, +): Promise { + const cache = queryClient.getQueryCache(); + const landed = () => + cache.findAll({ queryKey: [postsDataType] }).some((query) => { + const data = query.state.data as { posts?: Array> } | undefined; + return ( + String(query.queryKey[1]).includes(`/posts/${version.id}/`) && + data?.posts?.[0]?.updated_at === version.updated_at + ); + }); + + return new Promise((resolve) => { + // The cache hears of a read before its observers, which it tells on its next timer turn. + const handled = () => setTimeout(() => setTimeout(resolve)); + if (landed()) { + handled(); + return; + } + const unsubscribe = cache.subscribe(() => { + if (landed()) { + unsubscribe(); + handled(); + } + }); + }); +} + const UNSPLASH_REGULAR = 'https://images.unsplash.com/photo-1?ixid=1&w=1080'; // The picker asks Unsplash for a wider rendition of the image it inserts. export const UNSPLASH_PICKED = 'https://images.unsplash.com/photo-1?ixid=1&w=2000'; diff --git a/apps/admin/test-utils/acceptance/framework-props.ts b/apps/admin/test-utils/acceptance/framework-props.ts index 179d1844212..a214dbba51c 100644 --- a/apps/admin/test-utils/acceptance/framework-props.ts +++ b/apps/admin/test-utils/acceptance/framework-props.ts @@ -9,7 +9,7 @@ import { defaultUnsplashConfig, type TopLevelFrameworkProps } from '@tryghost/ad */ export function createFrameworkProps( overrides: Partial = {}, -): TopLevelFrameworkProps { +): TopLevelFrameworkProps & { queryClient: QueryClient } { const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -32,7 +32,7 @@ export function createFrameworkProps( onUpdate: () => {}, onInvalidate: () => {}, onDelete: () => {}, - queryClient, ...overrides, + queryClient: overrides.queryClient ?? queryClient, }; } diff --git a/apps/admin/test-utils/acceptance/index.ts b/apps/admin/test-utils/acceptance/index.ts index cb798d17ab5..27cb898f7b2 100644 --- a/apps/admin/test-utils/acceptance/index.ts +++ b/apps/admin/test-utils/acceptance/index.ts @@ -3,6 +3,7 @@ export { authToken, fakeSetupStatus, plainText, signedOut } from './auth'; export { fakeAnalyticsOverview } from './analytics'; export { UNSPLASH_PICKED, + editorReadLanded, fakeEditorChrome, fakeEditorPost, fakeUnsplashPhotos, diff --git a/apps/admin/test-utils/acceptance/render-admin-app.tsx b/apps/admin/test-utils/acceptance/render-admin-app.tsx index acb5b29fc38..9d8253c2fbd 100644 --- a/apps/admin/test-utils/acceptance/render-admin-app.tsx +++ b/apps/admin/test-utils/acceptance/render-admin-app.tsx @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/react-query'; import { render } from 'vitest-browser-react'; import '@/index.css'; @@ -42,12 +43,13 @@ export function currentRoute(): string { * Boots the real admin app (the same provider stack as src/main.tsx) at the * given hash route, e.g. "/tags" or "/members?filter=label:VIP". Cross-app * (Ember-owned) navigations are recorded on - * `document.body.dataset.externalNavigate` instead of navigating. + * `document.body.dataset.externalNavigate` instead of navigating. Resolves + * with the render and the `queryClient` it booted. */ export async function renderAdminApp( route: string = '/', { labs, boot, autosaveDebounceMs }: RenderAdminAppOptions = {}, -): Promise>> { +): Promise> & { queryClient: QueryClient }> { let overrides: BootOverrides = labs ? composeLabsBootOverrides(labs, boot) : { ...boot }; if (autosaveDebounceMs !== undefined) { overrides = composeConfigBootOverrides( @@ -89,5 +91,6 @@ export async function renderAdminApp( }, }); - return await render(, { container: rootElement }); + const rendered = await render(, { container: rootElement }); + return Object.assign(rendered, { queryClient: framework.queryClient }); } diff --git a/apps/admin/test-utils/acceptance/setup.ts b/apps/admin/test-utils/acceptance/setup.ts index 912c90194ba..24ce59391d7 100644 --- a/apps/admin/test-utils/acceptance/setup.ts +++ b/apps/admin/test-utils/acceptance/setup.ts @@ -3,10 +3,19 @@ import { cleanup } from 'vitest-browser-react'; import './matchers'; import { defaultBootResolver, defaultBootRoutes } from './boot'; -import { resetFakeApi, settleRequests, startFakeApi, verifyNoUnhandledRequests } from './worker'; +import { + resetFakeApi, + settleRequests, + startFakeApi, + trackIssuedRequests, + verifyNoUnhandledRequests, +} from './worker'; import { resetDeclaredResources } from './resources'; import { guardFrameNavigations, resetFakeFrameOrigins } from './frames'; +// At import, before the spec module (and the app) loads. +trackIssuedRequests(); + beforeAll(async () => { // Playwright waits for an element to stop moving before it acts on it, so every // click that opens an animated surface pays that animation — Shade's toaster-in diff --git a/apps/admin/test-utils/acceptance/worker.test.ts b/apps/admin/test-utils/acceptance/worker.test.ts index 2f0a6804d07..f7311bce184 100644 --- a/apps/admin/test-utils/acceptance/worker.test.ts +++ b/apps/admin/test-utils/acceptance/worker.test.ts @@ -19,6 +19,8 @@ function deferredResponse() { return { promise, resolve, reject }; } +const xhrMethods = Object.getOwnPropertyDescriptors(XMLHttpRequest.prototype); + describe('fake API request teardown', () => { let settleRequests: typeof import('./worker').settleRequests; let fetchMock: ReturnType>; @@ -28,12 +30,20 @@ describe('fake API request teardown', () => { vi.useFakeTimers(); fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); + // An XHR is held the same way: sent, but never delivered until the test + // fires its loadend. + XMLHttpRequest.prototype.send = () => {}; const worker = await import('./worker'); settleRequests = worker.settleRequests; + worker.trackIssuedRequests(); await worker.startFakeApi({ resolver: () => undefined, routes: [] }); }); afterEach(() => { + Object.defineProperties(XMLHttpRequest.prototype, { + open: xhrMethods.open, + send: xhrMethods.send, + }); vi.unstubAllGlobals(); vi.useRealTimers(); }); @@ -74,6 +84,18 @@ describe('fake API request teardown', () => { await draining; }); + it('removes a fetch that throws before returning from the ledger', async () => { + const error = new TypeError('Failed to construct Request'); + fetchMock.mockImplementation(() => { + throw error; + }); + + expect(() => window.fetch('/ghost/api/admin/stats/posts-member-counts/')).toThrow(error); + const draining = settleRequests(); + await vi.advanceTimersByTimeAsync(60); + await draining; + }); + it('reports the method and path of a fetch still waiting for MSW', async () => { const pending = deferredResponse(); fetchMock.mockReturnValue(pending.promise); @@ -104,4 +126,20 @@ describe('fake API request teardown', () => { pending.resolve(new Response('image')); await request; }); + + it('waits for an XHR upload that has not reached the service worker', async () => { + const upload = new XMLHttpRequest(); + upload.open('POST', '/ghost/api/admin/images/upload/'); + upload.send(); + const settled = vi.fn(); + const draining = settleRequests().then(settled); + + await vi.advanceTimersByTimeAsync(100); + expect(settled).not.toHaveBeenCalled(); + + upload.dispatchEvent(new Event('loadend')); + await vi.advanceTimersByTimeAsync(60); + await draining; + expect(settled).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/admin/test-utils/acceptance/worker.ts b/apps/admin/test-utils/acceptance/worker.ts index 3b7ce089ff6..6927765f6fc 100644 --- a/apps/admin/test-utils/acceptance/worker.ts +++ b/apps/admin/test-utils/acceptance/worker.ts @@ -113,27 +113,93 @@ export function verifyNoUnhandledRequests(): void { } } -// In-flight ledger for requests the worker owns; drained in afterEach so -// stragglers can't cross test boundaries. Symbols track fetch calls before -// the service worker delivers request:start; MSW request IDs also cover XHR. -const inFlightRequests = new Map(); - -function trackFetchRequests(): void { - const fetch = window.fetch.bind(window); - window.fetch = async (input, init) => { - const url = new URL(input instanceof Request ? input.url : input, window.location.href).href; - if (!isTrackedUrl(url)) { - return fetch(input, init); - } +// In-flight ledger (key → "METHOD path") for requests the worker owns; +// drained in afterEach so stragglers can't cross test boundaries. MSW only +// hears of a request once the service worker has relayed it, so the page also +// records each request from the moment it issues one (trackIssuedRequests) — +// otherwise a request still inside the service worker slips past the drain +// and lands in the next test. +const inFlightRequests = new Map(); + +function describeRequest(method: string, url: string): string { + const path = ADMIN_API_PATTERN.test(url) ? toAdminApiPath(url) : url; + return `${method.toUpperCase()} ${path}`; +} + +let issuedRequestCount = 0; + +/** Records a request the page issues, until the returned callback settles it. */ +function recordIssuedRequest(method: string, url: string): () => void { + if (!isTrackedUrl(url)) { + return () => {}; + } + issuedRequestCount += 1; + const key = `issued:${issuedRequestCount}`; + inFlightRequests.set(key, describeRequest(method, url)); + return () => { + inFlightRequests.delete(key); + }; +} - const id = Symbol(); +function absoluteUrl(url: string | URL): string | undefined { + try { + return new URL(url, document.baseURI).href; + } catch { + return undefined; + } +} + +let issuedRequestsTracked = false; + +/** + * Wraps fetch and XHR (the framework's upload path, for progress) so the + * ledger sees each request from the call. Install before any spec module + * loads, so nothing holds the unwrapped fetch. + */ +export function trackIssuedRequests(): void { + if (issuedRequestsTracked) { + return; + } + issuedRequestsTracked = true; + + const originalFetch = window.fetch.bind(window); + window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : absoluteUrl(input); const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); - const path = ADMIN_API_PATTERN.test(url) ? toAdminApiPath(url) : url; - inFlightRequests.set(id, `${method.toUpperCase()} ${path}`); + const settle = url ? recordIssuedRequest(method, url) : () => {}; + try { + return originalFetch(input, init).finally(settle); + } catch (error) { + settle(); + throw error; + } + }; + + // Unbound on purpose: each is re-applied to the request instance it wraps. + // eslint-disable-next-line @typescript-eslint/unbound-method + const { open, send } = XMLHttpRequest.prototype; + const opened = new WeakMap(); + XMLHttpRequest.prototype.open = function ( + this: XMLHttpRequest, + method: string, + url: string | URL, + ...rest: unknown[] + ) { + opened.set(this, { method, url: absoluteUrl(url) }); + Reflect.apply(open, this, [method, url, ...rest]); + }; + XMLHttpRequest.prototype.send = function ( + this: XMLHttpRequest, + body?: Document | XMLHttpRequestBodyInit | null, + ) { + const request = opened.get(this); + const settle = request?.url ? recordIssuedRequest(request.method, request.url) : () => {}; + this.addEventListener('loadend', settle, { once: true }); try { - return await fetch(input, init); - } finally { - inFlightRequests.delete(id); + send.call(this, body); + } catch (error) { + settle(); + throw error; } }; } @@ -141,8 +207,7 @@ function trackFetchRequests(): void { function trackInFlightRequests(worker: SetupWorker): void { worker.events.on('request:start', ({ request, requestId }) => { if (isTrackedUrl(request.url)) { - const path = ADMIN_API_PATTERN.test(request.url) ? toAdminApiPath(request.url) : request.url; - inFlightRequests.set(requestId, `${request.method} ${path}`); + inFlightRequests.set(requestId, describeRequest(request.method, request.url)); } }); // msw 2.x emits "request:end" for completed requests but only @@ -198,7 +263,8 @@ export async function settleRequests({ throw new Error( [ `Request(s) still in flight ${timeoutMs}ms after the test finished:`, - ...[...inFlightRequests.values()].map((description) => ` - ${description}`), + // The page and MSW can both hold the same request. + ...[...new Set(inFlightRequests.values())].map((description) => ` - ${description}`), ].join('\n'), ); } @@ -377,8 +443,9 @@ export type FakeAdminEndpointResponse = * Fake one admin API endpoint that has no resource fake. `apiPath` is * relative to /ghost/api/admin (string = exact including the query, RegExp = * test). `response` may be a synchronous or async function of the captured request — - * `({body}) => body` echoes. Returns a capture of every matched request. - * Prefer `defineResource` for browse endpoints. + * `({body}) => body` echoes. A `Response`, given or returned, is served as it is, so a + * function can answer with a status that depends on the request. Returns a capture of + * every matched request. Prefer `defineResource` for browse endpoints. */ export function fakeAdminEndpoint( method: string, @@ -405,6 +472,11 @@ export function fakeAdminEndpoint( const responseBody: unknown = typeof response === 'function' ? await response(captured) : response; + if (responseBody instanceof Response) { + // A body can be read only once, so every request is served its own copy. + return responseBody.clone(); + } + if (responseBody instanceof ArrayBuffer) { return new HttpResponse(responseBody, { status, @@ -519,11 +591,6 @@ export async function startFakeApi({ } } - // request:start arrives asynchronously through the service worker. Track - // fetch synchronously too, or teardown can see an empty ledger and remove - // a declared fake while its request is still travelling to MSW. - trackFetchRequests(); - return worker; } diff --git a/apps/shade/src/components/ui/token-field.stories.tsx b/apps/shade/src/components/ui/token-field.stories.tsx index 290eb528d3a..85829bd66e1 100644 --- a/apps/shade/src/components/ui/token-field.stories.tsx +++ b/apps/shade/src/components/ui/token-field.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { X, ChevronDown } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Stack } from '@/components/primitives/stack'; import { tokenFieldClasses } from '@/components/ui/token-field'; import { cn } from '@/lib/utils'; @@ -18,6 +20,36 @@ const meta: Meta = { }; export default meta; type Story = StoryObj; +export const InputHeight: Story = { + parameters: { + docs: { + description: { + story: 'Empty and single-row token fields stay the same height as a standard input.', + }, + }, + }, + render: () => ( + + +
+ +
+
+ + + + +
+
+ ), +}; export const Wrapping: Story = { parameters: { docs: { diff --git a/apps/shade/src/components/ui/token-field.ts b/apps/shade/src/components/ui/token-field.ts index 5b856ffb76b..d069f5aa22a 100644 --- a/apps/shade/src/components/ui/token-field.ts +++ b/apps/shade/src/components/ui/token-field.ts @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'; export const tokenFieldClasses = { field: cn( inputSurface('within'), - 'relative flex min-h-(--control-height) w-full cursor-text flex-wrap items-center gap-1 p-1 text-control', + 'relative flex min-h-(--control-height) w-full cursor-text flex-wrap items-center gap-1 p-0.75 text-control', ), input: 'min-w-20 flex-1 bg-transparent px-2 text-control outline-hidden placeholder:text-muted-foreground', diff --git a/apps/shade/src/docs/recipes-guide.mdx b/apps/shade/src/docs/recipes-guide.mdx index 9452751c244..4b51703cf4f 100644 --- a/apps/shade/src/docs/recipes-guide.mdx +++ b/apps/shade/src/docs/recipes-guide.mdx @@ -66,6 +66,8 @@ Browse the sidebar for the live list of recipes. and top-right chevron for token inputs. Member labels and editor tags/authors use this recipe while retaining their own selection and keyboard behavior. Compose `field`, `input`, `chip` and optional `chevron`; add right padding when using the -chevron so wrapped chips cannot overlap it. See **Recipes / Token Field**. +chevron so wrapped chips cannot overlap it. Empty and single-row fields match the +standard input height; the field grows only when tokens wrap onto another row. +See **Recipes / Token Field**. diff --git a/e2e/tests/admin/posts/editor-access-react.test.ts b/e2e/tests/admin/posts/editor-access-react.test.ts index 98118c1606f..58a37836990 100644 --- a/e2e/tests/admin/posts/editor-access-react.test.ts +++ b/e2e/tests/admin/posts/editor-access-react.test.ts @@ -172,7 +172,7 @@ test.describe('Ghost Admin - Post editor access (React)', () => { await editor.publishFlow.open(); await expect(editor.publishFlow.optionsStep).toBeVisible(); await editor.publishFlow.confirm(); - await expect(editor.publishFlow.completeStep).toBeVisible(); + await expect(page).toHaveURL('/ghost/#/posts'); const published = await readAccess(page, postId); expect(published).toMatchObject({ diff --git a/e2e/tests/admin/posts/editor-settings-react.test.ts b/e2e/tests/admin/posts/editor-settings-react.test.ts index 5db55fa5a3c..6563479aeb5 100644 --- a/e2e/tests/admin/posts/editor-settings-react.test.ts +++ b/e2e/tests/admin/posts/editor-settings-react.test.ts @@ -284,13 +284,12 @@ async function startDraft(page: Page, { title, body }: { title: string; body: st return { editor, postId: await editor.getPostId() }; } -/** Publishes the open draft and dismisses the flow so the editor is reachable again. */ +/** Publishes the open draft and waits for the automatic return to the post list. */ async function publish(page: Page, editor: PostEditorPage, postId: string): Promise { await editor.publishFlow.open(); await expect(editor.publishFlow.optionsStep).toBeVisible(); await Promise.all([waitForPostSave(page, postId), editor.publishFlow.confirm()]); - await expect(editor.publishFlow.completeStep).toBeVisible(); - await editor.publishFlow.dismiss(); + await expect(page).toHaveURL('/ghost/#/posts'); } /** The tag names the page head carries, in the order Ghost writes them. */ diff --git a/e2e/tests/admin/posts/publishing-react.test.ts b/e2e/tests/admin/posts/publishing-react.test.ts index ba9a2aa6a2a..4f3844bbbec 100644 --- a/e2e/tests/admin/posts/publishing-react.test.ts +++ b/e2e/tests/admin/posts/publishing-react.test.ts @@ -93,18 +93,18 @@ test.describe('Ghost Admin - Publishing (React)', () => { // default schedule is ten minutes out await editor.publishFlow.schedule({}); await Promise.all([waitForPostSave(page, postId), editor.publishFlow.confirm()]); - await expect(editor.publishFlow.completeStep).toBeVisible(); + await expect(page).toHaveURL('/ghost/#/posts'); const scheduled = await readPost(page, postId); expect(scheduled.status).toBe('scheduled'); - await postsPage.goto(); await postsPage.waitForPageToFullyLoad(); await expect(postsPage.getPostByTitle(title)).toContainText('Scheduled'); - - // The publish flow's complete modal outlives an in-app hash route change - await page.reload(); - await postsPage.waitForPageToFullyLoad(); + // The Ember list consumes the handoff and opens its own success modal. + const celebration = new PostEditorPage(page, { implementation: 'ember' }).publishFlow; + await expect(celebration.completeStep).toBeVisible(); + await celebration.close(); + await expect(celebration.completeStep).toBeHidden(); await postsPage.getPostByTitle(title).click(); await expect(editor.postStatus).toContainText('Scheduled'); @@ -252,7 +252,7 @@ test.describe('Ghost Admin - Publishing (React)', () => { await expect(editor.publishFlow.optionsStep).toBeVisible(); await editor.publishFlow.selectPublishType('publish'); await editor.publishFlow.confirm(); - await expect(editor.publishFlow.completeStep).toBeVisible(); + await expect(page).toHaveURL('/ghost/#/posts'); const post = await readPost(page, postId); expect(post.status).toBe('published'); @@ -285,7 +285,7 @@ test.describe('Ghost Admin - Publishing (React)', () => { await expect(editor.publishFlow.optionsStep).toBeVisible(); await editor.publishFlow.selectPublishType('publish+send'); await editor.publishFlow.confirm(); - await expect(editor.publishFlow.completeStep).toBeVisible(); + await expect(page).toHaveURL(`/ghost/#/posts/analytics/${postId}`); const post = await readPost(page, postId); expect(post.status).toBe('published'); diff --git a/ghost/core/core/server/api/endpoints/automation-performance-stats.ts b/ghost/core/core/server/api/endpoints/automation-performance-stats.ts new file mode 100644 index 00000000000..fd909f56527 --- /dev/null +++ b/ghost/core/core/server/api/endpoints/automation-performance-stats.ts @@ -0,0 +1,17 @@ +import * as automationsApi from '../../services/automations/automations-api'; + +/** @type {import('@tryghost/api-framework').Controller} */ +const controller = { + docName: 'automation_performance_stats', + read: { + headers: { cacheInvalidate: false }, + data: ['id'], + options: ['timezone'], + permissions: { docName: 'automations', method: 'read' }, + async query(frame: { data: { id: string }; options: { timezone?: unknown } }) { + return await automationsApi.readPerformanceStats(frame.data.id, frame.options); + }, + }, +}; + +module.exports = controller; diff --git a/ghost/core/core/server/api/endpoints/index.js b/ghost/core/core/server/api/endpoints/index.js index 78959946c9f..b98d70da2d6 100644 --- a/ghost/core/core/server/api/endpoints/index.js +++ b/ghost/core/core/server/api/endpoints/index.js @@ -11,6 +11,10 @@ module.exports = { return apiFramework.pipeline(require('./automations').controller, localUtils); }, + get automationPerformanceStats() { + return apiFramework.pipeline(require('./automation-performance-stats'), localUtils); + }, + get automationActionLinks() { return apiFramework.pipeline(require('./automation-action-links'), localUtils); }, diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js index 89d3696843b..35079523c24 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js @@ -4,6 +4,9 @@ const mappers = require('./mappers'); module.exports = { all(data, apiConfig, frame) { debug('all'); + if (data?.thumbnail_url) { + data.thumbnail_url = mappers.oembed(data.thumbnail_url); + } if (data?.metadata?.thumbnail) { data.metadata.thumbnail = mappers.oembed(data.metadata.thumbnail); } diff --git a/ghost/core/core/server/data/tinybird/README.md b/ghost/core/core/server/data/tinybird/README.md index 7884f11d9cd..02cfa8ec1d4 100644 --- a/ghost/core/core/server/data/tinybird/README.md +++ b/ghost/core/core/server/data/tinybird/README.md @@ -82,3 +82,27 @@ sources before adding test data to it. ### Architecture [See full documentation regarding analytics architecture in following document](ARCHITECTURE.md) + +### Automation statistics + +Run rows are sorted by `(site_uuid, automation_id, id)` and step rows by +`(site_uuid, automation_run_id, id)`. Ownership is immutable and the individual +row ID remains in each key, preserving latest-version deduplication. The site +prefix also supports the automation list. Queries use `FINAL` before aggregating. + +Changing sorting keys rebuilds the materialized tables from the raw event +datasources, which have no TTL. Deploy the related datafiles together. + +The performance pipe classifies each run once, then fills a daily calendar with zero counts +for missing dates through today in the requested timezone. Core derives the chart and status totals from that same +result. The latest step categories use a bit mask: pending=1, finished=2, known +exit=4, unknown=8. Any unknown bit fails the API request. Otherwise pending wins; +finished-only is 2; known exits with or without finished steps are 6 or 4. Runs +without steps are excluded by the inner join. The automation list continues using +its existing pending-run view. + +Do not turn these into incrementing materialized counters without a retraction +or deduplication strategy: inserted versions include retries and status changes. +Run `tb test run` to check the performance pipe against the committed fixtures, +including duplicates, old versions, missing history, unknown statuses, daily +buckets, and site isolation. diff --git a/ghost/core/core/server/data/tinybird/datasources/_mv_automation_run_steps.datasource b/ghost/core/core/server/data/tinybird/datasources/_mv_automation_run_steps.datasource index f393cc6140f..05c39888575 100644 --- a/ghost/core/core/server/data/tinybird/datasources/_mv_automation_run_steps.datasource +++ b/ghost/core/core/server/data/tinybird/datasources/_mv_automation_run_steps.datasource @@ -6,5 +6,5 @@ SCHEMA > `updated_at` DateTime64(3) ENGINE ReplacingMergeTree -ENGINE_SORTING_KEY site_uuid, id +ENGINE_SORTING_KEY site_uuid, automation_run_id, id ENGINE_VER updated_at diff --git a/ghost/core/core/server/data/tinybird/datasources/_mv_automation_runs.datasource b/ghost/core/core/server/data/tinybird/datasources/_mv_automation_runs.datasource index 265244e7aaa..7ef67abba39 100644 --- a/ghost/core/core/server/data/tinybird/datasources/_mv_automation_runs.datasource +++ b/ghost/core/core/server/data/tinybird/datasources/_mv_automation_runs.datasource @@ -6,5 +6,5 @@ SCHEMA > `updated_at` DateTime64(3) ENGINE ReplacingMergeTree -ENGINE_SORTING_KEY site_uuid, id +ENGINE_SORTING_KEY site_uuid, automation_id, id ENGINE_VER updated_at diff --git a/ghost/core/core/server/data/tinybird/endpoints/api_automation_performance_stats.pipe b/ghost/core/core/server/data/tinybird/endpoints/api_automation_performance_stats.pipe new file mode 100644 index 00000000000..e519feb745e --- /dev/null +++ b/ghost/core/core/server/data/tinybird/endpoints/api_automation_performance_stats.pipe @@ -0,0 +1,78 @@ +DESCRIPTION > + Daily entries and current outcomes from the same recorded runs. + +NODE selected_status_runs +SQL > + % + SELECT id, created_at + FROM _mv_automation_runs FINAL + WHERE site_uuid = {{ String(site_uuid, required=True) }} + AND automation_id = {{ String(automation_id, required=True) }} + +NODE run_status_flags +SQL > + % + SELECT + automation_run_id, + -- Combine categories after FINAL has selected each step's latest version. + groupBitOr(toUInt8(multiIf( + status = 'pending', 1, + status = 'finished', 2, + status IN ('automation disabled', 'failed', 'member changed status', 'member unsubscribed'), 4, + 8 + ))) AS status_flags + FROM _mv_automation_run_steps FINAL + WHERE site_uuid = {{ String(site_uuid, required=True) }} + AND automation_run_id IN (SELECT id FROM selected_status_runs) + GROUP BY automation_run_id + +NODE performance_counts +SQL > + % + SELECT + toString(toDate(runs.created_at, {{ String(timezone, 'UTC') }})) AS date, + -- Unknown statuses are reported to Core as an error, even alongside pending steps. + countIf(bitAnd(status_flags, 8) = 0 AND bitAnd(status_flags, 1) != 0) AS in_progress_run_count, + countIf(status_flags = 2) AS completed_run_count, + countIf(status_flags IN (4, 6)) AS exited_early_run_count, + countIf(bitAnd(status_flags, 8) != 0) AS invalid_run_count + FROM selected_status_runs AS runs + -- A run without recorded steps contributes to neither the chart nor the cards. + INNER JOIN run_status_flags AS steps ON steps.automation_run_id = runs.id + GROUP BY date + ORDER BY date ASC + +NODE entry_bounds +SQL > + % + -- Clock override keeps calendar tests deterministic, like api_kpis. + WITH + {% if defined(current_time) %} + toDate(parseDateTime64BestEffort({{ String(current_time) }}, 3, 'UTC'), {{ String(timezone, 'UTC') }}) + {% else %} + toDate(now(), {{ String(timezone, 'UTC') }}) + {% end %} AS today + SELECT + coalesce(minOrNull(toDate(date)), today) AS first_date, + greatest(coalesce(maxOrNull(toDate(date)), today), today) AS last_date + FROM performance_counts + +NODE calendar +SQL > + SELECT + toString(addDays(first_date, arrayJoin(range(toUInt32(dateDiff('day', first_date, last_date) + 1))))) AS date + FROM entry_bounds + +NODE filled_performance_counts +SQL > + SELECT + calendar.date, + coalesce(counts.in_progress_run_count, 0) AS in_progress_run_count, + coalesce(counts.completed_run_count, 0) AS completed_run_count, + coalesce(counts.exited_early_run_count, 0) AS exited_early_run_count, + coalesce(counts.invalid_run_count, 0) AS invalid_run_count + FROM calendar + LEFT JOIN performance_counts AS counts ON calendar.date = counts.date + ORDER BY calendar.date + +TYPE ENDPOINT diff --git a/ghost/core/core/server/data/tinybird/fixtures/automation_run_events.ndjson b/ghost/core/core/server/data/tinybird/fixtures/automation_run_events.ndjson index 34ef4a08d74..f34830febfd 100644 --- a/ghost/core/core/server/data/tinybird/fixtures/automation_run_events.ndjson +++ b/ghost/core/core/server/data/tinybird/fixtures/automation_run_events.ndjson @@ -5,3 +5,23 @@ {"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-3","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-02-01T03:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-3","automation_id":"automation-1","created_at":"2029-12-31T01:00:00.000Z","updated_at":"2020-01-01T01:00:00.000Z"}} {"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-4","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-4","automation_id":"automation-2","created_at":"2020-01-01T01:00:00.000Z","updated_at":"2020-01-01T01:00:00.000Z"}} {"site_uuid":"6cce57c1-c0df-4771-bb86-53cbf494f90f","id":"run-1","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"6cce57c1-c0df-4771-bb86-53cbf494f90f","id":"run-1","automation_id":"automation-1","created_at":"2020-01-01T01:00:00.000Z","updated_at":"2020-01-01T01:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-1","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-1","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-2","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-2","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-3","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-3","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-4","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-4","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-5","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-5","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-6","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-6","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-7","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-7","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-8","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-8","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-9","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-9","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-10","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-10","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-11","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-11","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"other-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000001","id":"status-run-3","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000001","id":"status-run-3","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-1","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-run-1","updated_at":"2020-01-01T01:00:00.000Z","automation_id":"status-automation","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-pending_unknown","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_id":"case-pending_unknown","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-exit_without_finished","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_id":"case-exit_without_finished","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-unknown_exit","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_id":"case-unknown_exit","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-finished_only","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_id":"case-finished_only","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-missing_steps","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_id":"case-missing_steps","created_at":"2020-01-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"summary-next-day","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_id":"summary-dates","created_at":"2020-02-01T00:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"summary-first-day","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_id":"summary-dates","created_at":"2020-01-01T00:00:00.000Z"}} diff --git a/ghost/core/core/server/data/tinybird/fixtures/automation_run_step_events.ndjson b/ghost/core/core/server/data/tinybird/fixtures/automation_run_step_events.ndjson index 318fe158fcd..fadd0e255cb 100644 --- a/ghost/core/core/server/data/tinybird/fixtures/automation_run_step_events.ndjson +++ b/ghost/core/core/server/data/tinybird/fixtures/automation_run_step_events.ndjson @@ -6,3 +6,36 @@ {"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-3","updated_at":"2020-01-01T03:00:00.000Z","inserted_at":"2020-01-01T04:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-3","automation_run_id":"run-2","status":"finished","updated_at":"2020-01-01T03:00:00.000Z"}} {"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-4","updated_at":"2020-01-01T05:00:00.000Z","inserted_at":"2020-01-01T06:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-4","automation_run_id":"run-2","status":"pending","updated_at":"2020-01-01T05:00:00.000Z"}} {"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-4","updated_at":"2020-01-01T07:00:00.000Z","inserted_at":"2020-01-01T08:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-4","automation_run_id":"run-2","status":"pending","updated_at":"2020-01-01T07:00:00.000Z"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-1-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-1-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-1","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-1-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-1-1","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-1","status":"pending"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-1-2","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-1-2","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-1","status":"pending"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-2-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-2-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-2","status":"failed"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-2-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-2-1","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-2","status":"pending"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-3-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-3-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-3","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-3-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-3-1","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-3","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-4-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-4-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-4","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-5-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-5-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-5","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-5-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-5-1","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-5","status":"failed"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-6-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-6-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-6","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-6-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-6-1","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-6","status":"automation disabled"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-7-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-7-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-7","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-7-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-7-1","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-7","status":"member changed status"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-8-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-8-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-8","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-8-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-8-1","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-8","status":"member unsubscribed"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-10-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-10-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-10","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-10-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-10-1","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-10","status":"future status"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-11-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-11-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-11","status":"pending"}} +{"site_uuid":"15840000-0000-4000-8000-000000000001","id":"status-step-3-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000001","id":"status-step-3-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-3","status":"pending"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-1-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-1-0","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-1","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-3-0","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-3-0","updated_at":"2020-01-01T01:00:00.000Z","automation_run_id":"status-run-3","status":"pending"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-orphan-step","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T01:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-orphan-step","updated_at":"2020-01-01T01:00:00.000Z","automation_run_id":"missing-status-run","status":"pending"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-2-unknown","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"status-step-2-unknown","updated_at":"2020-01-01T02:00:00.000Z","automation_run_id":"status-run-2","status":"future status"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-pending_unknown-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_run_id":"case-pending_unknown","status":"pending"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-pending_unknown-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_run_id":"case-pending_unknown","status":"future status"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-exit_without_finished-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_run_id":"case-exit_without_finished","status":"failed"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-unknown_exit-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_run_id":"case-unknown_exit","status":"future status"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-unknown_exit-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_run_id":"case-unknown_exit","status":"failed"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-finished_only-0","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_run_id":"case-finished_only","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"case-finished_only-1","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_run_id":"case-finished_only","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"summary-first-day-step","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_run_id":"summary-first-day","status":"finished"}} +{"site_uuid":"15840000-0000-4000-8000-000000000000","id":"summary-next-day-step","updated_at":"2020-01-01T02:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"automation_run_id":"summary-next-day","status":"pending"}} diff --git a/ghost/core/core/server/data/tinybird/tests/api_automation_performance_stats.yaml b/ghost/core/core/server/data/tinybird/tests/api_automation_performance_stats.yaml new file mode 100644 index 00000000000..51e7f2a6ec0 --- /dev/null +++ b/ghost/core/core/server/data/tinybird/tests/api_automation_performance_stats.yaml @@ -0,0 +1,108 @@ +- name: recorded_outcomes_and_latest_versions + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=status-automation¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":1,"completed_run_count":2,"exited_early_run_count":4,"invalid_run_count":2} + +- name: other_automation + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=other-automation¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":1,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + +- name: other_site + parameters: site_uuid=15840000-0000-4000-8000-000000000001&automation_id=status-automation¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":1,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + +- name: empty_history + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=missing¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + +- name: pending_unknown + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=case-pending_unknown¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":1} + +- name: exit_without_finished + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=case-exit_without_finished¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":1,"invalid_run_count":0} + +- name: unknown_exit + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=case-unknown_exit¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":1} + +- name: finished_only + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=case-finished_only¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":0,"completed_run_count":1,"exited_early_run_count":0,"invalid_run_count":0} + +- name: missing_steps + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=case-missing_steps¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + +- name: missing_site + parameters: automation_id=status-automation + expected_http_status: 400 + expected_result: >- + Template Syntax Error: Required parameter is not defined. Check the parameters site_uuid and automation_id. Please provide a value or set a default value in the pipe code. + +- name: missing_automation + parameters: site_uuid=15840000-0000-4000-8000-000000000000 + expected_http_status: 400 + expected_result: >- + Template Syntax Error: Required parameter is not defined. Check the parameters site_uuid and automation_id. Please provide a value or set a default value in the pipe code. + +- name: daily_chart_and_counts + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=summary-dates¤t_time=2020-02-01T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":0,"completed_run_count":1,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-02","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-03","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-04","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-05","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-06","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-07","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-08","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-09","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-10","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-11","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-12","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-13","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-14","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-15","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-16","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-17","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-18","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-19","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-20","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-21","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-22","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-23","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-24","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-25","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-26","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-27","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-28","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-29","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-30","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-01-31","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + {"date":"2020-02-01","in_progress_run_count":1,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + +- name: local_day_grouping + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=status-automation&timezone=America/New_York¤t_time=2020-01-01T12:00:00Z + expected_result: | + {"date":"2019-12-31","in_progress_run_count":1,"completed_run_count":2,"exited_early_run_count":4,"invalid_run_count":2} + {"date":"2020-01-01","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + +- name: empty_history_local_today + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=missing&timezone=America/New_York¤t_time=2024-03-11T01:00:00Z + expected_result: | + {"date":"2024-03-10","in_progress_run_count":0,"completed_run_count":0,"exited_early_run_count":0,"invalid_run_count":0} + +- name: future_entries_remain_visible + parameters: site_uuid=15840000-0000-4000-8000-000000000000&automation_id=case-finished_only¤t_time=2019-12-30T12:00:00Z + expected_result: | + {"date":"2020-01-01","in_progress_run_count":0,"completed_run_count":1,"exited_early_run_count":0,"invalid_run_count":0} diff --git a/ghost/core/core/server/services/auth/session/index.js b/ghost/core/core/server/services/auth/session/index.js index a77c44ccc16..b6d5ccf27a9 100644 --- a/ghost/core/core/server/services/auth/session/index.js +++ b/ghost/core/core/server/services/auth/session/index.js @@ -88,11 +88,13 @@ module.exports.createSessionFromToken = () => { ssoAdapter.setUserRepository({ async getByEmail(email) { const user = await models.User.findOne({ email }); - return user ? { id: user.id, email: user.get('email') } : null; + return user ? { id: user.id, email: user.get('email'), status: user.get('status') } : null; }, async getOwner() { const owner = await models.User.findOne({ role: 'Owner', status: 'all' }); - return owner ? { id: owner.id, email: owner.get('email') } : null; + return owner + ? { id: owner.id, email: owner.get('email'), status: owner.get('status') } + : null; }, }); diff --git a/ghost/core/core/server/services/automations/README.md b/ghost/core/core/server/services/automations/README.md index 4149ab0f618..917cd9c70de 100644 --- a/ghost/core/core/server/services/automations/README.md +++ b/ghost/core/core/server/services/automations/README.md @@ -6,7 +6,7 @@ Automations run a series of actions for a member after signup. Each automation i The member repository calls `trigger()` after signup. -The database repository creates run(s) and queues their first step. Ghost's boot process starts the service, which checks for ready steps. It checks again when a new run starts or a later step becomes ready. An in-memory timer handles checks while Ghost is running; the scheduler can wake Ghost after a restart. +The database repository creates run(s) and queues their first step(s). Ghost's boot process starts the service, which checks for ready steps. It checks again when a new run starts or a later step becomes ready. An in-memory timer handles checks while Ghost is running; the scheduler can wake Ghost after a restart. Each check locks ready steps, then runs up to 100 at once. A `wait` action advances when its time arrives. A `send_email` action checks the member's current status and email preference before sending. Disabled automations and ineligible members stop. @@ -17,3 +17,68 @@ Each check locks ready steps, then runs up to 100 at once. A `wait` action advan - [`service.ts`](service.ts) starts checks and schedules future ones. - [`poll.ts`](poll.ts) runs ready steps. - [`welcome-email-automation-poll.ts`](welcome-email-automation-poll.ts) processes older welcome email runs. + +## Performance statistics + +`GET /ghost/api/admin/automations/:id/performance-stats/` requires permission to +read the selected automation. Unknown IDs return 404. The existence check does +not load actions, email contents, or email statistics. + +```json +{ + "automation_performance_stats": [{ + "automation_id": "…", + "total_run_count": 12, + "in_progress_run_count": 4, + "completed_run_count": 6, + "exited_early_run_count": 2, + "entries": [{"date": "2026-09-14", "count": 12}], + "entry_window": { + "date_from": "2026-09-14", + "date_to": "2026-09-15", + "bucket": "day", + "timezone": "UTC" + } + }] +} +``` + +One Tinybird query classifies each run using its latest recorded steps and groups +the counts by entry date. Core derives both the chart and the three status totals +from those same daily rows. The cards sum to the total entries count. + +- **In progress:** any pending step, provided every step has a known status. +- **Completed:** all recorded steps finished. +- **Exited early:** no pending steps, and at least one failed, automation-disabled, + member-status-changed, or member-unsubscribed step. + +Runs with no recorded steps are excluded from both chart and cards. An unexpected +step status fails the request, including when another step is pending. There is no +partial-success message or fourth user-facing status category. + +One entry is one run, including repeat entries and deleted members. Tinybird fills missing days +with zero from the first included entry through today. Empty histories +return a zero total and one zero bucket for today. `date_from` is inclusive and +`date_to` is exclusive. The optional `timezone` parameter accepts an IANA timezone and defaults to UTC. +It controls daily grouping and today’s date. Admin should pass the browser +timezone, consistent with web analytics. This endpoint has no date-filter parameters yet. + +Admin uses the shared analytics grouping rules: daily under 91 days, weekly for +91–270 days, and monthly for longer spans. Grouping does not limit the history to +the web analytics 1,000-day fetch window. + +The chart and cards share the request and cache, populated on first sidebar open +and kept until navigation. Closing, reopening, focus, and reconnect do not refresh +it. Failed requests, including a missing endpoint, show an inline error and retry +without guessing whether the backend is older. + +## Availability + +The performance endpoint requires `automationsTinybirdSync`. A disabled flag, +missing configuration/token, unavailable pipe, or invalid response produces an +error. The automation list retains its MySQL fallback for those cases; a successful +empty Tinybird response still returns zero counts rather than falling back. + +The `automationRunAnalytics` flag controls presentation. See the +[Tinybird storage notes](../../data/tinybird/README.md#automation-statistics) +for sorting keys, migration behavior, and query tests. diff --git a/ghost/core/core/server/services/automations/automation-entry-stats.ts b/ghost/core/core/server/services/automations/automation-entry-stats.ts new file mode 100644 index 00000000000..4c8ca346376 --- /dev/null +++ b/ghost/core/core/server/services/automations/automation-entry-stats.ts @@ -0,0 +1,50 @@ +import errors from '@tryghost/errors'; +import moment from 'moment-timezone'; +import { z } from 'zod'; + +export type EntryStatsWindow = { + date_from: string; + date_to: string; + bucket: 'day'; + timezone: string; +}; + +export type EntryStatsData = { + total_run_count: number; + entries: { date: string; count: number }[]; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const messages = { + invalidTimezone: 'Invalid automation statistics timezone.', +}; + +const timezoneSchema = z + .string() + .refine((value) => !!moment.tz.zone(value)) + .transform((value) => moment.tz.zone(value)!.name) + .default('UTC'); + +export function parseEntryStatsTimezone(timezone: unknown): string { + const parsed = timezoneSchema.safeParse(timezone); + if (!parsed.success) { + throw new errors.ValidationError({ message: messages.invalidTimezone }); + } + return parsed.data; +} + +// Tinybird returns the complete, ordered calendar, including today's zero for empty histories. +export function getEntryStatsWindow( + entries: EntryStatsData['entries'], + timezone = 'UTC', +): EntryStatsWindow { + return { + date_from: entries[0].date, + date_to: new Date(Date.parse(entries[entries.length - 1].date) + DAY_MS) + .toISOString() + .slice(0, 10), + bucket: 'day', + timezone, + }; +} diff --git a/ghost/core/core/server/services/automations/automations-api.ts b/ghost/core/core/server/services/automations/automations-api.ts index 1c6b8a432e5..98825e2dfd9 100644 --- a/ghost/core/core/server/services/automations/automations-api.ts +++ b/ghost/core/core/server/services/automations/automations-api.ts @@ -6,7 +6,12 @@ import { z } from 'zod'; import { createDatabaseAutomationsRepository } from './database-automations-repository'; import { parseFakeWaitHoursMultiplier } from './fake-wait-hours-multiplier'; import type { AutomationsRepository, EditAutomationData } from './automations-repository'; -import { EMPTY_AUTOMATION_STATS, fetchAutomationStats } from './tinybird-automation-stats'; +import { + EMPTY_AUTOMATION_STATS, + fetchAutomationStats, + fetchAutomationPerformanceStats, +} from './tinybird-automation-stats'; +import { getEntryStatsWindow, parseEntryStatsTimezone } from './automation-entry-stats'; import { StartAutomationsPollEvent } from './events/start-automations-poll-event'; const { knex } = require('../../data/db'); @@ -22,6 +27,8 @@ const lexicalLib = require('../../lib/lexical'); const MAX_AUTOMATION_ACTIONS = 50; const messages = { + tinybirdPerformanceStatsFailed: 'Could not load Tinybird automation performance stats.', + automationNotFound: 'Automation not found.', automationActionNotFound: 'Automation action not found.', invalidAutomationPayload: 'Automation edit payload must include status, actions, and edges.', @@ -135,6 +142,36 @@ export async function read(automationId: string) { return automation; } +export async function readPerformanceStats( + automationId: string, + options: { timezone?: unknown } = {}, +) { + const exists = await repository.exists(automationId); + if (!exists) { + throw new errors.NotFoundError({ message: tpl(messages.automationNotFound) }); + } + + const client = getTinybirdClient(); + if (!client) { + throw new errors.InternalServerError({ + message: tpl(messages.tinybirdPerformanceStatsFailed), + }); + } + const timezone = parseEntryStatsTimezone(options.timezone); + const stats = await fetchAutomationPerformanceStats(client, automationId, timezone); + if (stats === null) { + throw new errors.InternalServerError({ + message: tpl(messages.tinybirdPerformanceStatsFailed), + }); + } + const entryWindow = getEntryStatsWindow(stats.entries, timezone); + return { + automation_id: automationId, + ...stats, + entry_window: entryWindow, + }; +} + export async function browseActionLinks(automationId: string, actionId: string) { const links = await repository.getAutomationActionLinks(automationId, actionId); diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts index 2ff222887ab..08d61946e2c 100644 --- a/ghost/core/core/server/services/automations/automations-repository.ts +++ b/ghost/core/core/server/services/automations/automations-repository.ts @@ -116,8 +116,7 @@ type AutomationStepBase = { locked_by: string; automation_run_id: string; automation_id: string; - // NOTE: This property will be removed once we support additional automation triggers. - automation_slug: null | string; + automation_trigger_tier_scope: null | AutomationTriggerTierScope; automation_status: 'inactive' | 'active'; member_id: string | null; member_email: string; @@ -161,6 +160,7 @@ export type BrowseOptions = Readonly<{ export type AutomationsRepository = { browse(options: BrowseOptions): Promise>; + exists(id: string): Promise; getById(id: string): Promise; getAutomationActionLinks( automationId: string, diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts index f9ecb1168e5..7f5dcfb9f2b 100644 --- a/ghost/core/core/server/services/automations/database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/database-automations-repository.ts @@ -47,6 +47,10 @@ const DEFAULT_WELCOME_EMAIL_AUTOMATIONS = [ trigger_tier_scope: 'all_paid', }, ] as const; +const TRIGGER_TIER_SCOPE_BY_MEMBER_STATUS = { + free: 'free', + paid: 'all_paid', +} as const satisfies Record<'free' | 'paid', AutomationTriggerTierScope>; const messages = { invalidAutomationActionRevision: @@ -111,6 +115,24 @@ type EdgeRow = { target_action_id: string; }; +type RunToInsert = { + id: string; + created_at: DatabaseDate; + updated_at: DatabaseDate; + automation_id: string; + member_id: string; + member_email: string; +}; + +type StepToInsert = { + id: string; + created_at: DatabaseDate; + updated_at: DatabaseDate; + automation_run_id: string; + automation_action_revision_id: string; + ready_at: DatabaseDate; +}; + type NextActionRevisionRow = { automation_id: string; action_id: string; @@ -124,7 +146,7 @@ type StepToRunRow = { locked_by: string; automation_run_id: string; automation_id: string; - automation_slug: null | string; + automation_trigger_tier_scope: null | AutomationTriggerTierScope; automation_status: 'inactive' | 'active'; member_id: string | null; member_email: string; @@ -183,6 +205,10 @@ export function createDatabaseAutomationsRepository({ }); }, + async exists(id) { + return !!(await knex('automations').where({ id }).first('id')); + }, + async getById(id: string): Promise { return await knex.transaction(async (trx) => { const automation = await loadAutomation(trx, id); @@ -619,67 +645,88 @@ async function trigger( await lockMemberForTriggering(trx, memberId); - const firstAction = await findFirstActionRevision(trx, memberStatus); - if (!firstAction) { - return; - } + const firstActions = await findFirstActionRevisions(trx, memberStatus); - const automationId = firstAction.automation_id; + const runsToInsert: RunToInsert[] = []; + const stepsToInsert: StepToInsert[] = []; - if (await hasMemberAlreadyEnteredAutomation(trx, automationId, memberId)) { - logging.info( - `Skipping automation ${automationId} for member ${memberId} because they have already run it`, - ); - return; - } + await Promise.all( + firstActions.map(async (firstAction) => { + const automationId = firstAction.automation_id; - const now = new Date(); - const nowString = toDatabaseDate(now); + if (await hasMemberAlreadyEnteredAutomation(trx, automationId, memberId)) { + logging.info( + `Skipping automation ${automationId} for member ${memberId} because they have already run it`, + ); + return; + } - const readyAt = getReadyAtForAction(firstAction, now, fakeWaitHoursMultiplier); + const now = new Date(); + const nowString = toDatabaseDate(now); - const run = { - id: ObjectId().toHexString(), - created_at: nowString, - updated_at: nowString, - automation_id: automationId, - member_id: memberId, - member_email: memberEmail, - }; + const readyAt = getReadyAtForAction(firstAction, now, fakeWaitHoursMultiplier); - await trx('automation_runs').insert(run); - await insertRunStep(trx, { - automationRunId: run.id, - automationActionRevisionId: firstAction.automation_action_revision_id, - now, - readyAt, - }); + const run = { + id: ObjectId().toHexString(), + created_at: nowString, + updated_at: nowString, + automation_id: automationId, + member_id: memberId, + member_email: memberEmail, + }; + runsToInsert.push(run); + + const step = createRunStep({ + automationRunId: run.id, + automationActionRevisionId: firstAction.automation_action_revision_id, + now, + readyAt, + }); + stepsToInsert.push(step); + }), + ); + + if (runsToInsert.length > 0) { + await Promise.all([ + trx('automation_runs').insert(runsToInsert), + trx('automation_run_steps').insert(stepsToInsert), + ]); + } } async function insertRunStep( trx: Knex.Transaction, - { - automationRunId, - automationActionRevisionId, - now, - readyAt, - }: ReadonlyDeep<{ + options: ReadonlyDeep<{ automationRunId: string; automationActionRevisionId: string; now: Date; readyAt: Date; }>, ): Promise { + await trx('automation_run_steps').insert(createRunStep(options)); +} + +function createRunStep({ + automationRunId, + automationActionRevisionId, + now, + readyAt, +}: ReadonlyDeep<{ + automationRunId: string; + automationActionRevisionId: string; + now: Date; + readyAt: Date; +}>): StepToInsert { const nowString = toDatabaseDate(now); - await trx('automation_run_steps').insert({ + return { id: ObjectId().toHexString(), created_at: nowString, updated_at: nowString, automation_run_id: automationRunId, automation_action_revision_id: automationActionRevisionId, ready_at: toDatabaseDate(readyAt), - }); + }; } async function fetchAndLockSteps( @@ -748,7 +795,7 @@ async function fetchAndLockSteps( 'step.locked_by as locked_by', 'step.automation_run_id as automation_run_id', 'run.automation_id as automation_id', - 'automation.slug as automation_slug', + 'automation.trigger_tier_scope as automation_trigger_tier_scope', 'automation.status as automation_status', 'run.member_id as member_id', 'run.member_email as member_email', @@ -803,7 +850,7 @@ function buildStepToRun(row: ReadonlyDeep): AutomationStepToRun { locked_by: row.locked_by, automation_run_id: row.automation_run_id, automation_id: row.automation_id, - automation_slug: row.automation_slug, + automation_trigger_tier_scope: row.automation_trigger_tier_scope, automation_status: row.automation_status, member_id: row.member_id, member_email: row.member_email, @@ -833,13 +880,11 @@ function buildStepToRun(row: ReadonlyDeep): AutomationStepToRun { } } -async function findFirstActionRevision( +async function findFirstActionRevisions( trx: Knex.Transaction, memberStatus: 'free' | 'paid', -): Promise { - const automationSlug: NonNullable = MEMBER_WELCOME_EMAIL_SLUGS[memberStatus]; - - const row = await trx('automations as automation') +): Promise { + return await trx('automations as automation') .select( 'automation.id as automation_id', 'actions.id as action_id', @@ -849,7 +894,7 @@ async function findFirstActionRevision( ) .innerJoin('automation_actions as actions', 'actions.automation_id', 'automation.id') .innerJoin('automation_action_revisions as revisions', 'revisions.action_id', 'actions.id') - .where('automation.slug', automationSlug) + .where('automation.trigger_tier_scope', TRIGGER_TIER_SCOPE_BY_MEMBER_STATUS[memberStatus]) .where('automation.status', 'active') .whereNull('actions.deleted_at') .whereNotExists( @@ -869,10 +914,7 @@ async function findFirstActionRevision( .max('created_at') .where('action_id', trx.ref('actions.id')), ) - .orderBy(['actions.created_at', 'actions.id']) - .first(); - - return row ?? null; + .orderBy(['actions.created_at', 'actions.id']); } async function finishStepAndEnqueueNext( diff --git a/ghost/core/core/server/services/automations/poll.ts b/ghost/core/core/server/services/automations/poll.ts index 2d6c587169d..081d7817212 100644 --- a/ghost/core/core/server/services/automations/poll.ts +++ b/ghost/core/core/server/services/automations/poll.ts @@ -1,11 +1,13 @@ -import type { AutomationStepToRun, AutomationsRepository } from './automations-repository'; +import type { + AutomationStepToRun, + AutomationTriggerTierScope, + AutomationsRepository, +} from './automations-repository'; import { getMailgunMessageId } from '../lib/mailgun-message-id'; +import { getMailgunError } from '../lib/mailgun-error'; import logging from '@tryghost/logging'; import errors from '@tryghost/errors'; -import { - MEMBER_WELCOME_EMAIL_ELIGIBLE_STATUSES, - MEMBER_WELCOME_EMAIL_SLUGS, -} from '../member-welcome-emails/constants'; +import { MEMBER_WELCOME_EMAIL_ELIGIBLE_STATUSES } from '../member-welcome-emails/constants'; import { MAX_ATTEMPTS, MAX_STEPS_PER_BATCH, RETRY_DELAY_MS } from './constants'; // @ts-expect-error Models currently lack type definitions. import { Member } from '../../models'; @@ -59,12 +61,11 @@ type PollOptions = { memberWelcomeEmailService: MemberWelcomeEmailService; }; -const slugToMemberStatus = new Map( - Object.entries(MEMBER_WELCOME_EMAIL_SLUGS).map(([status, slug]) => [ - slug as string, - status as 'free' | 'paid', - ]), -); +// TODO(NY-1643) Add support for "selected_paid" trigger tier scope. +const MEMBER_STATUS_BY_TRIGGER_TIER_SCOPE = new Map([ + ['free', 'free'], + ['all_paid', 'paid'], +]); const hasUpdatesAndAnnouncementsEnabled = (member: MemberModel): boolean => { const preference = member.get('enable_updates_and_announcements'); @@ -104,7 +105,7 @@ const handleStepExecutionFailure = async ({ }>): Promise => { logging.error( { - err, + err: getMailgunError(err), system: { event: 'automations.poll.step_execution_failed', step_id: step.id, @@ -150,19 +151,32 @@ const processStep = async ({ } // NOTE: This will change once we support additional automation triggers. - const memberStatus = step.automation_slug - ? slugToMemberStatus.get(step.automation_slug) - : undefined; + const triggerTierScope = step.automation_trigger_tier_scope; + if (!triggerTierScope) { + logging.error( + { + system: { + event: 'automations.poll.missing_trigger_tier_scope', + step_id: step.id, + }, + }, + `[AUTOMATIONS] Trigger tier scope is missing for step ${step.id}. Currently, this is unexpected`, + ); + await automationsApi.markStepTerminal(step, 'failed'); + return null; + } + + const memberStatus = MEMBER_STATUS_BY_TRIGGER_TIER_SCOPE.get(triggerTierScope); if (!memberStatus) { logging.error( { system: { - event: 'automations.poll.unknown_slug', - slug: step.automation_slug, + event: 'automations.poll.unsupported_trigger_tier_scope', + trigger_tier_scope: triggerTierScope, step_id: step.id, }, }, - `[AUTOMATIONS] Unknown automation slug: ${step.automation_slug}`, + `[AUTOMATIONS] Unsupported trigger tier scope for step ${step.id}: ${triggerTierScope}`, ); await automationsApi.markStepTerminal(step, 'failed'); return null; diff --git a/ghost/core/core/server/services/automations/tinybird-automation-stats.ts b/ghost/core/core/server/services/automations/tinybird-automation-stats.ts index d86bd7b5112..a23ec95e7c5 100644 --- a/ghost/core/core/server/services/automations/tinybird-automation-stats.ts +++ b/ghost/core/core/server/services/automations/tinybird-automation-stats.ts @@ -1,9 +1,17 @@ import logging from '@tryghost/logging'; import { z } from 'zod'; import type { AutomationBrowseResult } from './automations-repository'; +import type { EntryStatsData } from './automation-entry-stats'; export type TinybirdClient = { - fetch(pipeName: string, options: { version: string }): Promise; + fetch( + pipeName: string, + options: { + version: string; + automationId?: string; + timezone?: string; + }, + ): Promise; }; export type AutomationStats = NonNullable; @@ -34,7 +42,9 @@ export async function fetchAutomationStats( let rows: unknown; try { // Override the traffic analytics version: this pipe has no version suffix. - rows = await client.fetch('api_automation_browse_stats', { version: '' }); + rows = await client.fetch('api_automation_browse_stats', { + version: '', + }); } catch (error) { logging.error('Error fetching Tinybird automation stats:', error); return null; @@ -66,3 +76,66 @@ export async function fetchAutomationStats( ]), ); } + +export type AutomationPerformanceStats = EntryStatsData & { + in_progress_run_count: number; + completed_run_count: number; + exited_early_run_count: number; +}; + +const performanceRowSchema = z.object({ + date: z.iso.date(), + in_progress_run_count: runCountSchema, + completed_run_count: runCountSchema, + exited_early_run_count: runCountSchema, + invalid_run_count: runCountSchema.refine((count) => count === 0, { + message: 'Automation runs contain an unexpected step status.', + }), +}); + +export async function fetchAutomationPerformanceStats( + client: TinybirdClient, + automationId: string, + timezone = 'UTC', +): Promise { + try { + const rows = await client.fetch('api_automation_performance_stats', { + version: '', + automationId, + timezone, + }); + const parsed = z.array(performanceRowSchema).min(1).safeParse(rows); + if ( + !parsed.success || + new Set(parsed.data.map((row) => row.date)).size !== parsed.data.length + ) { + logging.error('Unexpected response from the Tinybird automation performance stats pipe'); + return null; + } + const stats: AutomationPerformanceStats = { + total_run_count: 0, + in_progress_run_count: 0, + completed_run_count: 0, + exited_early_run_count: 0, + entries: [], + }; + for (const row of parsed.data) { + const count = + row.in_progress_run_count + row.completed_run_count + row.exited_early_run_count; + stats.total_run_count += count; + stats.in_progress_run_count += row.in_progress_run_count; + stats.completed_run_count += row.completed_run_count; + stats.exited_early_run_count += row.exited_early_run_count; + stats.entries.push({ date: row.date, count }); + } + if (!Number.isSafeInteger(stats.total_run_count)) { + logging.error('Automation entry total exceeds the supported integer range'); + return null; + } + stats.entries.sort((a, b) => a.date.localeCompare(b.date)); + return stats; + } catch (error) { + logging.error('Error fetching Tinybird automation performance stats:', error); + return null; + } +} diff --git a/ghost/core/core/server/services/email-analytics/email-analytics-service.ts b/ghost/core/core/server/services/email-analytics/email-analytics-service.ts index e4eb42703ce..1e3a5c0e851 100644 --- a/ghost/core/core/server/services/email-analytics/email-analytics-service.ts +++ b/ghost/core/core/server/services/email-analytics/email-analytics-service.ts @@ -518,15 +518,12 @@ export class EmailAnalyticsService { unhandled: processingResult.unhandled, unprocessable: processingResult.unprocessable, }; - const beforeEmailIds = new Set(processingResult.emailIds); - const beforeMemberIds = new Set(processingResult.memberIds); - await eventProcessor.processBatch(events, processingResult, fetchData); processingTimeMs += Date.now() - processingStart; eventCount += events.length; // Calculate delta (only new counts from this batch) and accumulate for final reporting - const batchDelta = new EventProcessingResult({ + cumulativeResult.merge({ storedDelivered: processingResult.storedDelivered - beforeCounts.storedDelivered, storedOpened: processingResult.storedOpened - beforeCounts.storedOpened, storedPermanentFailed: @@ -539,10 +536,7 @@ export class EmailAnalyticsService { complained: processingResult.complained - beforeCounts.complained, unhandled: processingResult.unhandled - beforeCounts.unhandled, unprocessable: processingResult.unprocessable - beforeCounts.unprocessable, - emailIds: processingResult.emailIds.filter((id) => !beforeEmailIds.has(id)), - memberIds: processingResult.memberIds.filter((id) => !beforeMemberIds.has(id)), }); - cumulativeResult.merge(batchDelta); // Offer the event processor a chance to aggregate mid-fetch. try { diff --git a/ghost/core/core/server/services/email-analytics/event-processing-result.ts b/ghost/core/core/server/services/email-analytics/event-processing-result.ts index 92fdffd25a5..070167cc7f0 100644 --- a/ghost/core/core/server/services/email-analytics/event-processing-result.ts +++ b/ghost/core/core/server/services/email-analytics/event-processing-result.ts @@ -1,4 +1,4 @@ -type EventProcessingResultInput = Partial>; +type EventProcessingResultInput = Partial>; export class EventProcessingResult { // counts @@ -19,10 +19,14 @@ export class EventProcessingResult { // processing failures are counted separately in addition to event type counts processingFailures: number = 0; - // ids seen whilst processing ready for passing to stats aggregator + // ids seen whilst processing ready for passing to stats aggregator. + // Only write these through merge() and reset() so they stay in step with the sets below. emailIds: string[] = []; memberIds: string[] = []; + #emailIdSet = new Set(); + #memberIdSet = new Set(); + constructor(result: EventProcessingResultInput = {}) { this.merge(result); } @@ -42,6 +46,8 @@ export class EventProcessingResult { this.processingFailures = 0; this.emailIds = []; this.memberIds = []; + this.#emailIdSet.clear(); + this.#memberIdSet.clear(); } merge(other: EventProcessingResultInput = {}): void { @@ -60,12 +66,17 @@ export class EventProcessingResult { this.processingFailures += other.processingFailures || 0; - // TODO: come up with a cleaner way to merge these without churning through Array and Set - this.emailIds = Array.from(new Set([...this.emailIds, ...(other.emailIds || [])])).filter( - Boolean, - ); - this.memberIds = Array.from(new Set([...this.memberIds, ...(other.memberIds || [])])).filter( - Boolean, - ); + EventProcessingResult.#collect(this.#emailIdSet, this.emailIds, other.emailIds); + EventProcessingResult.#collect(this.#memberIdSet, this.memberIds, other.memberIds); + } + + // Only visit incoming IDs; rebuilding the accumulated arrays per event is quadratic. + static #collect(seen: Set, ordered: string[], ids?: readonly string[] | null): void { + for (const id of ids ?? []) { + if (id && !seen.has(id)) { + seen.add(id); + ordered.push(id); + } + } } } diff --git a/ghost/core/core/server/services/lib/mailgun-error.ts b/ghost/core/core/server/services/lib/mailgun-error.ts new file mode 100644 index 00000000000..567d727358d --- /dev/null +++ b/ghost/core/core/server/services/lib/mailgun-error.ts @@ -0,0 +1,9 @@ +/** + * Mailgun clients reject with `{ error, messageData }`. + * + * If passed such an object, we return `error`. Otherwise, we return the input value. + */ +export const getMailgunError = (err: unknown): unknown => + typeof err === 'object' && err !== null && 'error' in err && err.error instanceof Error + ? err.error + : err; diff --git a/ghost/core/core/server/services/oembed/oembed-service.js b/ghost/core/core/server/services/oembed/oembed-service.js index 1678fda31b8..704efb06d7d 100644 --- a/ghost/core/core/server/services/oembed/oembed-service.js +++ b/ghost/core/core/server/services/oembed/oembed-service.js @@ -84,6 +84,31 @@ const shouldRasterize = (buffer, ext) => { return looksLikeSvg(buffer, SVG_SNIFF_BYTES); }; +const YOUTUBE_MAXRES_THUMBNAIL_WIDTH = 1280; +const YOUTUBE_MAXRES_THUMBNAIL_HEIGHT = 720; + +/** + * YouTube's oEmbed thumbnail is a letterboxed 4:3 `hqdefault.jpg`. Videos with + * an HD upload also have a 1280x720 `maxresdefault.jpg` without the borders. + * + * @param {string} thumbnailUrl + * @returns {string|undefined} + */ +const getYouTubeMaxResThumbnailUrl = (thumbnailUrl) => { + if (!URL.canParse(thumbnailUrl)) { + return; + } + + const url = new URL(thumbnailUrl); + const isYouTubeImage = url.hostname === 'ytimg.com' || url.hostname.endsWith('.ytimg.com'); + if (!isYouTubeImage || !url.pathname.endsWith('/hqdefault.jpg')) { + return; + } + + url.pathname = url.pathname.replace(/hqdefault\.jpg$/, 'maxresdefault.jpg'); + return url.href; +}; + /** * @param {string} url * @returns {{url: string, provider: boolean}} @@ -128,6 +153,13 @@ const findUrlWithProvider = (url) => { * @typedef {import('got').GotRequestFunction} IExternalRequest */ +/** + * @typedef {object} EmbedThumbnail + * @prop {string} url + * @prop {number|string|null} [width] + * @prop {number|string|null} [height] + */ + /** * @typedef {object} ICustomProvider * @prop {(url: URL) => Promise} canSupportRequest @@ -271,6 +303,39 @@ class OEmbedService { return this.imageStore.saveRaw(imageBuffer, targetPath); } + /** + * Stores a provider's thumbnail, preferring YouTube's un-letterboxed max + * resolution image. Falls back to the provider's thumbnail if the image + * can't be stored. + * + * @param {EmbedThumbnail} thumbnail + * @returns {Promise} + */ + async storeThumbnail(thumbnail) { + const maxResUrl = getYouTubeMaxResThumbnailUrl(thumbnail.url); + if (maxResUrl) { + try { + return { + url: await this.processImageFromUrl(maxResUrl, 'thumbnail'), + width: YOUTUBE_MAXRES_THUMBNAIL_WIDTH, + height: YOUTUBE_MAXRES_THUMBNAIL_HEIGHT, + }; + } catch { + // YouTube 404s for videos without a max resolution thumbnail + } + } + + try { + return { + ...thumbnail, + url: await this.processImageFromUrl(thumbnail.url, 'thumbnail'), + }; + } catch (err) { + logging.error(err); + return thumbnail; + } + } + /** * Fetch bookmark enrichment from an allowlisted oEmbed provider without * exposing provider-supplied HTML. @@ -754,6 +819,37 @@ class OEmbedService { * @returns {Promise} */ async fetchOembedDataFromUrl(url, type, options = {}) { + const data = await this.#fetchOembedDataFromUrl(url, type, options); + + // Mentions aren't stored in content and can be triggered by third + // parties sending webmentions, so their images are never downloaded + if (type === 'mention' || !data?.thumbnail_url) { + return data; + } + + const thumbnail = await this.storeThumbnail({ + url: data.thumbnail_url, + width: data.thumbnail_width, + height: data.thumbnail_height, + }); + + return { + ...data, + thumbnail_url: thumbnail.url, + thumbnail_width: thumbnail.width, + thumbnail_height: thumbnail.height, + thumbnail_url_original: data.thumbnail_url, + }; + } + + /** + * @param {string} url + * @param {string} type + * @param {Object} options + * + * @returns {Promise} + */ + async #fetchOembedDataFromUrl(url, type, options) { const { shouldRethrowFetchError, ...fetchOptions } = options; try { diff --git a/ghost/core/core/server/services/tinybird/tinybird-service.js b/ghost/core/core/server/services/tinybird/tinybird-service.js index 78ee103d7e2..693802abbf8 100644 --- a/ghost/core/core/server/services/tinybird/tinybird-service.js +++ b/ghost/core/core/server/services/tinybird/tinybird-service.js @@ -59,6 +59,7 @@ const TINYBIRD_PIPES = [ 'api_top_devices', 'api_gift_link_visits', 'api_automation_browse_stats', + 'api_automation_performance_stats', // v2 pipes (materialized view optimization) 'api_kpis_v2', 'api_active_visitors_v2', diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index df7e9e4905b..7142af25196 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -301,6 +301,11 @@ module.exports = function apiRoutes() { http(api.automationActionLinks.browse), ); router.get('/automations/:id', mw.authAdminApi, http(api.automations.read)); + router.get( + '/automations/:id/performance-stats', + mw.authAdminApi, + http(api.automationPerformanceStats.read), + ); router.post( '/automations/:id/email_preview', mw.authAdminApi, diff --git a/ghost/core/test/e2e-api/admin/automation-performance-stats.test.ts b/ghost/core/test/e2e-api/admin/automation-performance-stats.test.ts new file mode 100644 index 00000000000..32052efabfe --- /dev/null +++ b/ghost/core/test/e2e-api/admin/automation-performance-stats.test.ts @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import nock from 'nock'; +import ObjectId from 'bson-objectid'; +import type { Knex } from 'knex'; +import { afterEach, beforeAll, beforeEach, describe, it } from 'vitest'; +const models = require('../../../core/server/models'); +const TinybirdServiceWrapper = require('../../../core/server/services/tinybird'); +const configUtils = require('../../utils/config-utils'); +import { agentProvider, fixtureManager, mockManager } from '../../utils/e2e-framework'; +import { + setupAutomationsFixture, + cleanupAutomationsFixture, +} from '../../utils/automations-fixtures'; + +describe('Automation performance stats API', function () { + let agent: Awaited>; + let automationId: string; + let clock: sinon.SinonFakeTimers; + const zeroRow = (date = '2026-09-14') => ({ + date, + in_progress_run_count: 0, + completed_run_count: 0, + exited_early_run_count: 0, + invalid_run_count: 0, + }); + + beforeAll(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('users'); + await agent.loginAsOwner(); + }); + + beforeEach(async function () { + await setupAutomationsFixture(); + automationId = (await models.Base.knex('automations').first('id')).id; + clock = sinon.useFakeTimers({ now: new Date('2026-09-14T12:00:00Z'), toFake: ['Date'] }); + mockManager.mockLabsDisabled('automationsTinybirdSync'); + }); + + afterEach(async function () { + clock.restore(); + sinon.restore(); + nock.cleanAll(); + mockManager.restore(); + await cleanupAutomationsFixture(); + }); + + async function requestStats(status: number) { + const queries: string[] = []; + const capture = (query: { sql: string }) => queries.push(query.sql); + models.Base.knex.on('query', capture); + try { + return await agent.get(`automations/${automationId}/performance-stats`).expectStatus(status); + } finally { + models.Base.knex.removeListener('query', capture); + assert.ok( + queries.every( + (sql) => !/\bautomation_runs\b|\bautomation_run_steps\b|\bautomation_actions\b/.test(sql), + ), + ); + } + } + + it('fails with the flag disabled without querying SQL statistics', async function () { + await requestStats(500); + }); + + it('returns 404 for an unknown automation even with Tinybird disabled', async function () { + await agent.get(`automations/${ObjectId().toHexString()}/performance-stats`).expectStatus(404); + }); + + it('requires permission to read automations', async function () { + await agent.loginAsAuthor(); + try { + await agent.get(`automations/${automationId}/performance-stats`).expectStatus(403); + } finally { + await agent.loginAsOwner(); + } + }); + + describe('Tinybird', function () { + let previousTinybirdInstance: typeof TinybirdServiceWrapper.instance; + let siteUuid: string; + beforeEach(async function () { + previousTinybirdInstance = TinybirdServiceWrapper.instance; + mockManager.mockLabsEnabled('automationsTinybirdSync'); + configUtils.set('tinybird', { + workspaceId: 'test-workspace-id', + adminToken: 'test-admin-token', + stats: { endpoint: 'https://api.tinybird.co', version: 'v2' }, + }); + TinybirdServiceWrapper.init(); + siteUuid = (await models.Settings.findOne({ key: 'site_uuid' })).get('value'); + }); + afterEach(async function () { + await configUtils.restore(); + TinybirdServiceWrapper.instance = previousTinybirdInstance; + }); + + it('returns 500 when the automation existence lookup fails', async function () { + sinon.stub(require('@tryghost/logging'), 'error'); + const client: Knex.Client = models.Base.knex.client; + const originalQuery = client.query; + let lookupFailures = 0; + sinon.stub(client, 'query').callsFake(function ( + this: typeof client, + connection: unknown, + query: { sql: string }, + ) { + if (/from `automations`/.test(query.sql)) { + lookupFailures += 1; + return Promise.reject(new Error('Automation lookup unavailable')); + } + return originalQuery.call(this, connection, query); + }); + const requests = mockStats(200, { data: [zeroRow()] }); + await agent.get(`automations/${automationId}/performance-stats`).expectStatus(500); + assert.equal(lookupFailures, 1); + assert.equal(requests.isDone(), false); + }); + + function mockStats( + status: number, + response: string | Record, + timezone = 'UTC', + ) { + return nock('https://api.tinybird.co') + .get('/v0/pipes/api_automation_performance_stats.json') + .query({ + ghost_client: 'server', + site_uuid: siteUuid, + automation_id: automationId, + timezone, + }) + .reply(status, response); + } + + it.each(['owner', 'admin'])('allows a %s staff JWT to read statistics', async function (role) { + const requests = mockStats(200, { data: [zeroRow()] }); + await agent.useStaffTokenFor(role); + try { + await agent.get(`automations/${automationId}/performance-stats`).expectStatus(200); + assert.ok(requests.isDone()); + } finally { + await agent.loginAsOwner(); + } + }); + + it('passes the normalized timezone to Tinybird and uses it for the response window', async function () { + clock.setSystemTime(new Date('2026-09-14T01:00:00Z')); + const requests = mockStats(200, { data: [zeroRow('2026-09-13')] }, 'America/New_York'); + const { body } = await agent + .get(`automations/${automationId}/performance-stats/?timezone=america%2Fnew_york`) + .expectStatus(200); + assert.ok(requests.isDone()); + assert.deepEqual(body.automation_performance_stats[0].entry_window, { + date_from: '2026-09-13', + date_to: '2026-09-14', + bucket: 'day', + timezone: 'America/New_York', + }); + }); + + it('rejects an invalid timezone before querying Tinybird', async function () { + const requests = mockStats(200, { data: [zeroRow()] }); + await agent + .get(`automations/${automationId}/performance-stats/?timezone=invalid`) + .expectStatus(422); + assert.equal(requests.isDone(), false); + }); + + it('returns the full history and matching total from one Tinybird query', async function () { + const start = Date.parse('2020-01-01'); + const dayMs = 24 * 60 * 60 * 1000; + const days = (Date.parse('2026-09-14') - start) / dayMs + 1; + const rows = Array.from({ length: days }, (_, day) => + zeroRow(new Date(start + day * dayMs).toISOString().slice(0, 10)), + ); + rows[0].completed_run_count = 1; + rows[rows.length - 1].in_progress_run_count = 1; + rows[rows.length - 1].exited_early_run_count = 1; + const requests = mockStats(200, { data: rows }); + const { body } = await requestStats(200); + assert.ok(requests.isDone()); + const stats = body.automation_performance_stats[0]; + assert.equal(stats.automation_id, automationId); + assert.deepEqual(stats.entry_window, { + date_from: '2020-01-01', + date_to: '2026-09-15', + bucket: 'day', + timezone: 'UTC', + }); + assert.equal(stats.entries.length, rows.length); + assert.equal(stats.total_run_count, 3); + assert.equal(stats.in_progress_run_count, 1); + assert.equal(stats.completed_run_count, 1); + assert.equal(stats.exited_early_run_count, 1); + assert.equal(stats.unclassified_run_count, undefined); + assert.equal( + stats.entries.reduce((sum: number, day: { count: number }) => sum + day.count, 0), + stats.total_run_count, + ); + assert.deepEqual(stats.entries[0], { date: '2020-01-01', count: 1 }); + assert.deepEqual(stats.entries.at(-1), { date: '2026-09-14', count: 2 }); + }); + + // Detailed invalid-value cases belong to fetchAutomationPerformanceStats unit tests. + it.each([ + { reason: 'missing pipe', status: 404, response: 'Missing pipe' }, + { reason: 'service unavailable', status: 503, response: 'Unavailable' }, + { reason: 'malformed data', status: 200, response: { data: [{ date: 'invalid' }] } }, + ])('returns 500 without SQL fallback for $reason', async function ({ status, response }) { + sinon.stub(require('@tryghost/logging'), 'error'); + const requests = mockStats(status, response); + await requestStats(500); + assert.ok(requests.isDone()); + }); + + it('fails when Tinybird configuration is missing', async function () { + configUtils.set('tinybird:stats', null); + await requestStats(500); + }); + + it('fails when the Tinybird token is unavailable', async function () { + sinon.stub(TinybirdServiceWrapper.instance, 'getToken').returns(null); + await requestStats(500); + }); + + it('preserves Tinybird’s zero bucket for an empty history', async function () { + const requests = mockStats(200, { data: [zeroRow()] }); + const { body } = await requestStats(200); + assert.ok(requests.isDone()); + assert.equal(body.automation_performance_stats[0].total_run_count, 0); + assert.equal(body.automation_performance_stats[0].in_progress_run_count, 0); + assert.equal(body.automation_performance_stats[0].completed_run_count, 0); + assert.equal(body.automation_performance_stats[0].exited_early_run_count, 0); + assert.deepEqual(body.automation_performance_stats[0].entries, [ + { date: '2026-09-14', count: 0 }, + ]); + }); + }); +}); diff --git a/ghost/core/test/e2e-api/admin/automations.test.js b/ghost/core/test/e2e-api/admin/automations.test.js index 8160afdb8d8..c90146ef0ac 100644 --- a/ghost/core/test/e2e-api/admin/automations.test.js +++ b/ghost/core/test/e2e-api/admin/automations.test.js @@ -458,6 +458,17 @@ describe('Automations API', function () { }); }); + it('uses database stats when Tinybird configuration is missing', async function () { + configUtils.set('tinybird:stats', null); + const [automation] = await models.Base.knex('automations').select('id'); + await createAutomationRun(automation.id, new Date('2026-01-01T00:00:00.000Z')); + const { body } = await agent.get('automations').expectStatus(200); + assert.equal( + body.automations.find((item) => item.id === automation.id).stats.total_run_count, + 1, + ); + }); + it('uses database stats when the flag is disabled', async function () { mockManager.mockLabsDisabled('automationsTinybirdSync'); const [automation] = await models.Base.knex('automations').select('id'); @@ -534,6 +545,7 @@ describe('Automations API', function () { ); it.each([ + { reason: 'missing', status: 404, response: 'missing pipe' }, { reason: 'unavailable', status: 500, response: 'nope' }, { reason: 'malformed', status: 200, response: { data: [{ automation_id: 42 }] } }, ])('uses database stats when Tinybird is $reason', async function ({ status, response }) { diff --git a/ghost/core/test/e2e-api/admin/oembed.test.js b/ghost/core/test/e2e-api/admin/oembed.test.js index 154313cfdc8..b30c7e8b019 100644 --- a/ghost/core/test/e2e-api/admin/oembed.test.js +++ b/ghost/core/test/e2e-api/admin/oembed.test.js @@ -70,6 +70,21 @@ describe('Oembed API', function () { assert.equal(requestMock.isDone(), true); assertExists(res.body.html); + assert.equal( + res.body.thumbnail_url, + `${urlUtils.urlFor('home', true)}content/images/thumbnail/image-01.png`, + ); + assert.equal(res.body.thumbnail_width, 1280); + assert.equal(res.body.thumbnail_height, 720); + assert.equal( + res.body.thumbnail_url_original, + 'https://i.ytimg.com/vi/E5yFcdPAGv0/hqdefault.jpg', + ); + sinon.assert.calledOnceWithExactly( + processImageFromUrlStub, + 'https://i.ytimg.com/vi/E5yFcdPAGv0/maxresdefault.jpg', + 'thumbnail', + ); }); it('does not use http preferentially to https', async function () { diff --git a/ghost/core/test/e2e-api/admin/sso.test.js b/ghost/core/test/e2e-api/admin/sso.test.js index 2535b462b8f..5219ed8b6d6 100644 --- a/ghost/core/test/e2e-api/admin/sso.test.js +++ b/ghost/core/test/e2e-api/admin/sso.test.js @@ -7,11 +7,16 @@ const { const { restore } = require('../../utils/e2e-framework-mock-manager'); const { stringMatching } = matchers; const sinon = require('sinon'); +const assert = require('node:assert/strict'); +const models = require('../../../core/server/models'); const adapterManager = require('../../../core/server/services/adapter-manager').default; const { SSOBase } = require('@tryghost/adapter-base-sso'); describe('SSO API', function () { let agent; + let observedOwner; + let ownerId; + let enabled = true; beforeAll(async function () { // Mock SSO adapter that always returns the owner. The stub stays registered @@ -23,9 +28,7 @@ describe('SSO API', function () { // below) would hit an unmigrated database. class MockSSOAdapter extends SSOBase { async getRequestCredentials() { - return { - id: 'mock-credentials', - }; + return enabled ? { id: 'mock-credentials' } : null; } async getIdentityFromCredentials() { @@ -35,7 +38,8 @@ describe('SSO API', function () { } async getUserForIdentity() { - return this.getOwnerUser(); + observedOwner = await this.getOwnerUser(); + return observedOwner?.status === 'active' ? observedOwner : null; } } @@ -77,6 +81,39 @@ describe('SSO API', function () { // Verify we can access authenticated endpoints after SSO login await agent.get('api/admin/users/me').expectStatus(200); + assert.equal(observedOwner.status, 'active'); + }); + }); + describe('owner status and first-run setup', function () { + beforeEach(async function () { + const owner = await models.User.findOne({ role: 'Owner', status: 'all' }); + ownerId = owner.id; + agent.resetAuthentication(); + enabled = true; + }); + + afterEach(async function () { + enabled = true; + await models.User.edit({ status: 'active' }, { id: ownerId, context: { internal: true } }); + agent.resetAuthentication(); + }); + + it('does not grant a session to an inactive first-run owner', async function () { + const owner = await models.User.findOne({ role: 'Owner', status: 'all' }); + await models.User.edit({ status: 'inactive' }, { id: owner.id, context: { internal: true } }); + const response = await agent.post('/').expectEmptyBody(); + assert.equal(response.headers['set-cookie'], undefined); + assert.equal(observedOwner.status, 'inactive'); + const setup = await agent.get('api/admin/authentication/setup').expectStatus(200); + assert.equal(setup.body.setup[0].status, false); + await agent.get('api/admin/users/me').expectStatus(403); + }); + + it('preserves normal authentication when a preview does not activate SSO', async function () { + enabled = false; + const response = await agent.post('/').expectEmptyBody(); + assert.equal(response.headers['set-cookie'], undefined); + await agent.get('api/admin/users/me').expectStatus(403); }); }); }); diff --git a/ghost/core/test/integration/migrations/schema-sync.test.ts b/ghost/core/test/integration/migrations/schema-sync.test.ts new file mode 100644 index 00000000000..19536385d00 --- /dev/null +++ b/ghost/core/test/integration/migrations/schema-sync.test.ts @@ -0,0 +1,479 @@ +import ObjectId from 'bson-objectid'; +import type { Knex } from 'knex'; +import KnexMigrator from 'knex-migrator'; +import { groupBy, kebabCase, omitBy, sortBy } from 'lodash'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import semver from 'semver'; +import { knex as dbKnex } from '../../../core/server/data/db'; +import { v4Schema } from '../../utils/fixtures/schema/v4'; +// @ts-expect-error This module lacks type definitions. +import rawSchema from '../../../core/server/data/schema/schema'; +// @ts-expect-error This module lacks type definitions. +import * as commands from '../../../core/server/data/schema/commands'; +import { isInDevelopmentTable } from '../../../core/server/data/schema/in-development'; +// @ts-expect-error This module lacks type definitions. +import * as dbUtils from '../../utils/db-utils'; + +const schema = rawSchema as SchemaTables; + +type SchemaTables = Record>; + +type ColumnSpec = { + type: string; + maxlength?: number; + fieldtype?: string; + nullable?: boolean; + defaultTo?: unknown; + unsigned?: boolean; + primary?: boolean; + unique?: boolean; + index?: boolean; + references?: string; + constraintName?: string; + cascadeDelete?: boolean; + restrictDelete?: boolean; + setNullDelete?: boolean; +}; + +type IndexSpec = string[] | { columns: string[] }; + +type NormalizedColumn = { + type: string; + maxlength?: number; + fieldtype?: string; + nullable: boolean; + defaultTo?: string; + unsigned?: true; + references?: string; + constraintName?: string; + onDelete?: string; +}; + +type NormalizedTable = { + columns: Record; + primaryKey: string[]; + indexes: string[][]; + uniques: string[][]; +}; + +type NormalizedSchema = Record; + +type ColumnRow = { + TABLE_NAME: string; + COLUMN_NAME: string; + DATA_TYPE: string; + COLUMN_TYPE: string; + CHARACTER_MAXIMUM_LENGTH: number | null; + IS_NULLABLE: 'YES' | 'NO'; + COLUMN_DEFAULT: string | null; +}; + +type IndexRow = { + TABLE_NAME: string; + INDEX_NAME: string; + COLUMN_NAME: string; + NON_UNIQUE: number; +}; + +type ForeignKeyRow = { + TABLE_NAME: string; + COLUMN_NAME: string; + CONSTRAINT_NAME: string; + REFERENCED_TABLE_NAME: string; + REFERENCED_COLUMN_NAME: string; + DELETE_RULE: string; +}; + +type FixtureEntry = Record; + +type Fixtures = { + models: { name: string; entries: FixtureEntry[] }[]; + relations: { from: { model: string }; entries: Record }[]; +}; + +type ExecutedMigration = { + name: string; + version: string; + currentVersion: string; +}; + +type FixtureId = { + entry: FixtureEntry; + id: string; +}; + +const MYSQL_TYPES = new Map([ + ['varchar', 'string'], + ['int', 'integer'], + ['bigint', 'bigInteger'], + ['datetime', 'dateTime'], + ['text', 'text'], + ['mediumtext', 'text'], + ['longtext', 'text'], + ['varbinary', 'binary'], + ['blob', 'binary'], +]); + +const MIGRATIONS_PATH = path.join(__dirname, '../../../core/server/data/migrations'); + +// The last v4 version with migrations. Sites must be on v4 before they can +// migrate to v5, so the versioned migrations start from a database at this +// version. +const V4_VERSION = '4.47'; + +// Fixtures are inserted with knex because today's models no longer match the v4 +// tables. Posts and tags are left out as no migration needs them. +const V4_FIXTURE_TABLES: Record = { + Product: 'products', + Newsletter: 'newsletters', + Role: 'roles', + Permission: 'permissions', + User: 'users', + Integration: 'integrations', +}; + +// Tables knex-migrator uses to track its own state; they are not in schema.js +const KNEX_MIGRATOR_TABLES = ['migrations', 'migrations_lock']; + +const knexMigrator = new KnexMigrator({ + knexMigratorFilePath: path.join(__dirname, '../../../'), +}); + +/** + * Reduces schema.js-style table definitions to what can be read back out of + * the database, so that they can be compared with `readSchemaFromDatabase()`. + */ +function normalizeSchema(tables: SchemaTables): NormalizedSchema { + const result: NormalizedSchema = {}; + + for (const [tableName, tableSpec] of Object.entries(tables)) { + const columns: Record = {}; + const indexes: string[][] = []; + const uniques: string[][] = []; + let primaryKey: string[] = []; + + for (const [columnName, value] of Object.entries(tableSpec)) { + if (columnName.startsWith('@@')) { + continue; + } + const spec = value as ColumnSpec; + + const column: NormalizedColumn = { type: spec.type, nullable: spec.nullable === true }; + + if (spec.type === 'string') { + column.maxlength = spec.maxlength ?? 191; + } else if (spec.type === 'binary' && spec.maxlength !== undefined) { + column.maxlength = spec.maxlength; + } + if (spec.type === 'text' && spec.fieldtype) { + column.fieldtype = spec.fieldtype; + } + if (Object.hasOwn(spec, 'defaultTo')) { + column.defaultTo = normalizeDefault(spec.defaultTo); + } + if (spec.unsigned) { + column.unsigned = true; + } + if (spec.references) { + column.references = spec.references; + column.constraintName = spec.constraintName ?? `${tableName}_${columnName}_foreign`; + if (spec.cascadeDelete) { + column.onDelete = 'CASCADE'; + } else if (spec.restrictDelete) { + column.onDelete = 'RESTRICT'; + } else if (spec.setNullDelete) { + column.onDelete = 'SET NULL'; + } else { + column.onDelete = 'NO ACTION'; + } + } + + if (spec.primary) { + primaryKey.push(columnName); + } + if (spec.unique) { + uniques.push([columnName]); + } + if (spec.index) { + indexes.push([columnName]); + } + + columns[columnName] = column; + } + + for (const index of (tableSpec['@@INDEXES@@'] ?? []) as IndexSpec[]) { + indexes.push(Array.isArray(index) ? index : index.columns); + } + for (const unique of (tableSpec['@@UNIQUE_CONSTRAINTS@@'] ?? []) as IndexSpec[]) { + uniques.push(Array.isArray(unique) ? unique : unique.columns); + } + if (tableSpec['@@PRIMARY_KEY@@']) { + primaryKey = tableSpec['@@PRIMARY_KEY@@'] as string[]; + } + + result[tableName] = { + columns, + primaryKey, + indexes: sortIndexes(indexes), + uniques: sortIndexes(uniques), + }; + } + + return result; +} + +function normalizeDefault(value: unknown): string { + switch (value) { + case true: + return '1'; + case false: + return '0'; + default: + return String(value); + } +} + +function sortIndexes(indexes: string[][]): string[][] { + return sortBy(indexes, (columns) => columns.join(',')); +} + +/** + * Reads the tables in the database into the same shape as `normalizeSchema()` + */ +async function readSchemaFromDatabase(knex: Knex): Promise { + const database = (knex.client.config.connection as Knex.ConnectionConfig).database; + + const columnRows: ColumnRow[] = await knex('information_schema.COLUMNS') + .where('TABLE_SCHEMA', database) + .whereNotIn('TABLE_NAME', KNEX_MIGRATOR_TABLES) + .whereIn( + 'TABLE_NAME', + knex('information_schema.TABLES') + .select('TABLE_NAME') + .where({ TABLE_SCHEMA: database, TABLE_TYPE: 'BASE TABLE' }), + ) + .orderBy(['TABLE_NAME', 'ORDINAL_POSITION']); + + const indexRows: IndexRow[] = await knex('information_schema.STATISTICS') + .where('TABLE_SCHEMA', database) + .whereNotIn('TABLE_NAME', KNEX_MIGRATOR_TABLES) + .orderBy(['TABLE_NAME', 'INDEX_NAME', 'SEQ_IN_INDEX']); + + const foreignKeyRows: ForeignKeyRow[] = await knex('information_schema.KEY_COLUMN_USAGE as k') + .join('information_schema.REFERENTIAL_CONSTRAINTS as r', function () { + this.on('k.CONSTRAINT_SCHEMA', 'r.CONSTRAINT_SCHEMA').andOn( + 'k.CONSTRAINT_NAME', + 'r.CONSTRAINT_NAME', + ); + }) + .where('k.TABLE_SCHEMA', database) + .select( + 'k.TABLE_NAME', + 'k.COLUMN_NAME', + 'k.CONSTRAINT_NAME', + 'k.REFERENCED_TABLE_NAME', + 'k.REFERENCED_COLUMN_NAME', + 'r.DELETE_RULE', + ); + + const result: NormalizedSchema = {}; + + for (const row of columnRows) { + result[row.TABLE_NAME] ??= { columns: {}, primaryKey: [], indexes: [], uniques: [] }; + + const type = + row.COLUMN_TYPE === 'tinyint(1)' + ? 'boolean' + : (MYSQL_TYPES.get(row.DATA_TYPE) ?? row.COLUMN_TYPE); + const column: NormalizedColumn = { type, nullable: row.IS_NULLABLE === 'YES' }; + + if (type === 'string' || type === 'binary') { + column.maxlength = Number(row.CHARACTER_MAXIMUM_LENGTH); + } + if (row.DATA_TYPE === 'mediumtext') { + column.fieldtype = 'medium'; + } else if (row.DATA_TYPE === 'longtext') { + column.fieldtype = 'long'; + } + if (row.COLUMN_DEFAULT !== null) { + column.defaultTo = row.COLUMN_DEFAULT; + } + if (row.COLUMN_TYPE.includes('unsigned')) { + column.unsigned = true; + } + + result[row.TABLE_NAME].columns[row.COLUMN_NAME] = column; + } + + for (const row of foreignKeyRows) { + Object.assign(result[row.TABLE_NAME].columns[row.COLUMN_NAME], { + references: `${row.REFERENCED_TABLE_NAME}.${row.REFERENCED_COLUMN_NAME}`, + constraintName: row.CONSTRAINT_NAME, + onDelete: row.DELETE_RULE, + }); + } + + // MySQL implicitly creates an index for a foreign key that no other index + // covers, and names it after the constraint. schema.js doesn't list those. + const foreignKeyNames = new Set(foreignKeyRows.map((row) => row.CONSTRAINT_NAME)); + + const indexesByTable = groupBy(indexRows, 'TABLE_NAME'); + for (const [tableName, rows] of Object.entries(indexesByTable)) { + const table = result[tableName]; + if (!table) { + continue; + } + for (const [indexName, indexColumns] of Object.entries(groupBy(rows, 'INDEX_NAME'))) { + const columns = indexColumns.map((row) => row.COLUMN_NAME); + if (indexName === 'PRIMARY') { + table.primaryKey = columns; + } else if (Number(indexColumns[0].NON_UNIQUE) === 0) { + table.uniques.push(columns); + } else if (!foreignKeyNames.has(indexName)) { + table.indexes.push(columns); + } + } + table.indexes = sortIndexes(table.indexes); + table.uniques = sortIndexes(table.uniques); + } + + return result; +} + +/** + * Builds the database as it was on the last v4 release, which is where the + * versioned migrations start from, and tells knex-migrator that everything up + * to then has run, then applies every later migration. + */ +async function createMigratedDatabase(knex: Knex) { + await knexMigrator.init({ noScripts: true }); + + for (const [tableName, tableSpec] of Object.entries(v4Schema)) { + await commands.createTable(tableName, knex, tableSpec); + } + + await knex.schema.createTable('migrations', function (table) { + table.increments().primary(); + table.string('name'); + table.string('version'); + table.string('currentVersion'); + }); + + const executed: ExecutedMigration[] = []; + for (const name of fs.readdirSync(path.join(MIGRATIONS_PATH, 'init'))) { + executed.push({ name, version: 'init', currentVersion: V4_VERSION }); + } + for (const version of fs.readdirSync(path.join(MIGRATIONS_PATH, 'versions'))) { + if (semver.lte(semver.coerce(version)!, semver.coerce(V4_VERSION)!)) { + for (const name of fs.readdirSync(path.join(MIGRATIONS_PATH, 'versions', version))) { + executed.push({ name, version, currentVersion: V4_VERSION }); + } + } + } + await knex('migrations').insert(executed); + + await insertV4Fixtures(knex); + await knexMigrator.migrate({ force: true }); +} + +async function insertV4Fixtures(knex: Knex) { + const v4Fixtures: Fixtures = JSON.parse( + fs.readFileSync(path.join(__dirname, '../../utils/fixtures/schema/v4-fixtures.json'), 'utf8'), + ); + const ids: Record = {}; + + for (const { name, entries } of v4Fixtures.models) { + const tableName = V4_FIXTURE_TABLES[name]; + if (!tableName) { + continue; + } + + ids[name] = []; + for (const entry of entries) { + const row: Record = { id: new ObjectId().toHexString() }; + for (const [columnName, value] of Object.entries(v4Schema[tableName])) { + const spec = value as ColumnSpec; + if (Object.hasOwn(entry, columnName) && columnName !== 'id') { + row[columnName] = entry[columnName]; + } else if (spec.nullable === false && !Object.hasOwn(spec, 'defaultTo')) { + if (spec.type === 'dateTime') { + row[columnName] = new Date(); + } else if (columnName === 'uuid') { + row[columnName] = crypto.randomUUID(); + } else if (columnName === 'created_by' || columnName === 'updated_by') { + row[columnName] = '1'; + } else if (columnName === 'slug') { + row[columnName] = kebabCase(entry.name as string); + } else if (columnName === 'password') { + row[columnName] = 'not-a-real-password-hash'; + } + } + } + await knex(tableName).insert(row); + ids[name].push({ entry, id: row.id as string }); + } + } + + const findId = (model: string, predicate: (entry: FixtureEntry) => boolean) => + ids[model].find(({ entry }) => predicate(entry))!.id; + + const rolePermissions = v4Fixtures.relations.find((r) => r.from.model === 'Role')!; + const roleEntries = rolePermissions.entries as Record>; + for (const [roleName, objectTypes] of Object.entries(roleEntries)) { + const roleId = findId('Role', (role) => role.name === roleName); + for (const [objectType, actionTypes] of Object.entries(objectTypes)) { + const permissions = ids.Permission.filter( + ({ entry }) => + entry.object_type === objectType && + (actionTypes === 'all' || actionTypes.includes(entry.action_type as string)), + ); + for (const { id: permissionId } of permissions) { + await knex('permissions_roles').insert({ + id: new ObjectId().toHexString(), + role_id: roleId, + permission_id: permissionId, + }); + } + } + } + + await knex('roles_users').insert({ + id: new ObjectId().toHexString(), + role_id: findId('Role', (role) => role.name === 'Owner'), + user_id: ids.User[0].id, + }); +} + +describe('migrations', function () { + beforeAll(async function () { + await dbUtils.teardown(); + await knexMigrator.reset({ force: true }); + }); + + afterAll(async function () { + await knexMigrator.reset({ force: true }); + await knexMigrator.init(); + }, 120_000); + + it('builds the schema in schema.js when every migration runs', async function () { + await createMigratedDatabase(dbKnex); + + const actualSchema = await readSchemaFromDatabase(dbKnex); + const expectedSchema = normalizeSchema( + omitBy(schema, (_tableSpec, tableName) => isInDevelopmentTable(tableName)), + ); + + // Known drift between migrations and schema.js. Sites that were created + // with `knex-migrator init` have what's in schema.js; sites that ran these + // migrations have what's below. + // + // TODO: Fix this drift. See NY-1482 and NY-1483. + expectedSchema.members_subscribe_events.columns.newsletter_id.nullable = false; + expectedSchema.post_revisions.columns.post_status.defaultTo = 'draft'; + + assert.deepEqual(actualSchema, expectedSchema); + }, 600_000); +}); diff --git a/ghost/core/test/integration/services/automations/database-automations-repository.test.ts b/ghost/core/test/integration/services/automations/database-automations-repository.test.ts index 447aac56db0..ed3cbdb1b05 100644 --- a/ghost/core/test/integration/services/automations/database-automations-repository.test.ts +++ b/ghost/core/test/integration/services/automations/database-automations-repository.test.ts @@ -22,6 +22,7 @@ describe('database automations repository', function () { slug: MEMBER_WELCOME_EMAIL_SLUGS.free, name: 'Free welcome automation', status: 'active', + trigger_tier_scope: 'free', created_at: now, updated_at: now, }); @@ -69,7 +70,7 @@ describe('database automations repository', function () { const bothMemberLocksRequested = Promise.withResolvers(); const timeout = setTimeout( () => bothMemberLocksRequested.reject(new Error('Triggers did not reach member lock')), - 5000, + 10_000, ); let memberLockCount = 0; const onKnexQuery = (query: { sql: string }) => { @@ -106,8 +107,8 @@ describe('database automations repository', function () { automation_id: automationId, member_id: memberId, }); - const steps = await knex('automation_run_steps').where('automation_run_id', runs[0].id); assert.equal(runs.length, 1); + const steps = await knex('automation_run_steps').where('automation_run_id', runs[0].id); assert.equal(steps.length, 1); }); @@ -127,7 +128,7 @@ describe('database automations repository', function () { const bothRunLookupsFinished = Promise.withResolvers(); const timeout = setTimeout( () => bothRunLookupsFinished.reject(new Error('Triggers did not reach run lookup')), - 5000, + 10_000, ); let runLookupCount = 0; const onKnexQueryResponse = (_response: unknown, query: { sql: string }) => { diff --git a/ghost/core/test/unit/api/cache-invalidation.test.js b/ghost/core/test/unit/api/cache-invalidation.test.ts similarity index 92% rename from ghost/core/test/unit/api/cache-invalidation.test.js rename to ghost/core/test/unit/api/cache-invalidation.test.ts index fdd021db53d..11532b0508d 100644 --- a/ghost/core/test/unit/api/cache-invalidation.test.js +++ b/ghost/core/test/unit/api/cache-invalidation.test.ts @@ -1,7 +1,7 @@ -const assert = require('node:assert/strict'); -const path = require('path'); +import assert from 'node:assert/strict'; +import path from 'path'; -const { globSync } = require('glob'); +import { globSync } from 'glob'; describe('API', function () { describe('Cache Invalidation', function () { diff --git a/ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.js b/ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.ts similarity index 87% rename from ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.js rename to ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.ts index 360699464c8..f50317288c8 100644 --- a/ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.js +++ b/ghost/core/test/unit/frontend/apps/private-blogging/input-password.test.ts @@ -1,9 +1,10 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../utils/assertions'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../utils/assertions'; // We use the name input_password to match the helper for consistency: // Stuff we are testing -const input_password = require('../../../../../core/frontend/apps/private-blogging/lib/helpers/input_password'); +// @ts-expect-error This module lacks type definitions. +import input_password from '../../../../../core/frontend/apps/private-blogging/lib/helpers/input_password'; describe('{{input_password}} helper', function () { it('has input_password helper', function () { diff --git a/ghost/core/test/unit/frontend/meta/cover-image.test.js b/ghost/core/test/unit/frontend/meta/cover-image.test.ts similarity index 94% rename from ghost/core/test/unit/frontend/meta/cover-image.test.js rename to ghost/core/test/unit/frontend/meta/cover-image.test.ts index 1ea1bf030f9..329b659f419 100644 --- a/ghost/core/test/unit/frontend/meta/cover-image.test.js +++ b/ghost/core/test/unit/frontend/meta/cover-image.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const getCoverImage = require('../../../../core/frontend/meta/cover-image'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import getCoverImage from '../../../../core/frontend/meta/cover-image'; describe('getCoverImage', function () { it('should return absolute cover image url for home', function () { diff --git a/ghost/core/test/unit/frontend/meta/structured-data.test.js b/ghost/core/test/unit/frontend/meta/structured-data.test.ts similarity index 97% rename from ghost/core/test/unit/frontend/meta/structured-data.test.js rename to ghost/core/test/unit/frontend/meta/structured-data.test.ts index c25af66bcab..fb7a5271666 100644 --- a/ghost/core/test/unit/frontend/meta/structured-data.test.js +++ b/ghost/core/test/unit/frontend/meta/structured-data.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const getStructuredData = require('../../../../core/frontend/meta/structured-data'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import getStructuredData from '../../../../core/frontend/meta/structured-data'; describe('getStructuredData', function () { it('should return structured data from metadata per post', function () { diff --git a/ghost/core/test/unit/frontend/services/data/checks.test.js b/ghost/core/test/unit/frontend/services/data/checks.test.ts similarity index 93% rename from ghost/core/test/unit/frontend/services/data/checks.test.js rename to ghost/core/test/unit/frontend/services/data/checks.test.ts index 88b24d8a809..e32db620928 100644 --- a/ghost/core/test/unit/frontend/services/data/checks.test.js +++ b/ghost/core/test/unit/frontend/services/data/checks.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const { checks } = require('../../../../../core/frontend/services/data'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import { checks } from '../../../../../core/frontend/services/data'; describe('Checks', function () { it('methods', function () { diff --git a/ghost/core/test/unit/frontend/services/llms/markdown.test.js b/ghost/core/test/unit/frontend/services/llms/markdown.test.ts similarity index 98% rename from ghost/core/test/unit/frontend/services/llms/markdown.test.js rename to ghost/core/test/unit/frontend/services/llms/markdown.test.ts index c438c54df89..0eefa9161a7 100644 --- a/ghost/core/test/unit/frontend/services/llms/markdown.test.js +++ b/ghost/core/test/unit/frontend/services/llms/markdown.test.ts @@ -1,5 +1,5 @@ -const assert = require('node:assert/strict'); -const { +import assert from 'node:assert/strict'; +import { collapseWhitespace, formatIsoDate, truncateDescription, @@ -10,7 +10,8 @@ const { markdownFromHtml, renderEntryMarkdown, renderEntryMarkdownBody, -} = require('../../../../../core/frontend/services/llms/markdown'); + // @ts-expect-error This module lacks type definitions. +} from '../../../../../core/frontend/services/llms/markdown'; describe('Unit: frontend/services/llms/markdown', function () { describe('collapseWhitespace', function () { diff --git a/ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.js b/ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.ts similarity index 88% rename from ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.js rename to ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.ts index 4cd1ed58319..e3d49eb9b51 100644 --- a/ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.js +++ b/ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.ts @@ -1,7 +1,5 @@ -const assert = require('node:assert/strict'); -const { - toExpressNotation, -} = require('../../../../../core/frontend/services/routing/permalink-adapter'); +import assert from 'node:assert/strict'; +import { toExpressNotation } from '../../../../../core/frontend/services/routing/permalink-adapter'; describe('services/routing/permalink-adapter', function () { describe('toExpressNotation', function () { diff --git a/ghost/core/test/unit/frontend/services/routing/rss-router.test.js b/ghost/core/test/unit/frontend/services/routing/rss-router.test.ts similarity index 69% rename from ghost/core/test/unit/frontend/services/routing/rss-router.test.js rename to ghost/core/test/unit/frontend/services/routing/rss-router.test.ts index 8d1d6c6d4ab..bef0a4f184e 100644 --- a/ghost/core/test/unit/frontend/services/routing/rss-router.test.js +++ b/ghost/core/test/unit/frontend/services/routing/rss-router.test.ts @@ -1,10 +1,13 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../utils/assertions'); -const sinon = require('sinon'); -const configUtils = require('../../../../utils/config-utils'); -const controllers = require('../../../../../core/frontend/services/routing/controllers'); -const RSSRouter = require('../../../../../core/frontend/services/routing/rss-router'); -const urlUtils = require('../../../../../core/shared/url-utils').default; +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../utils/assertions'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import configUtils from '../../../../utils/config-utils'; +// @ts-expect-error This module lacks type definitions. +import controllers from '../../../../../core/frontend/services/routing/controllers'; +// @ts-expect-error This module lacks type definitions. +import RSSRouter from '../../../../../core/frontend/services/routing/rss-router'; +import urlUtils from '../../../../../core/shared/url-utils'; describe('services/routing/RSSRouter', function () { describe('instantiate', function () { diff --git a/ghost/core/test/unit/frontend/services/theme-engine/config.test.js b/ghost/core/test/unit/frontend/services/theme-engine/config.test.ts similarity index 84% rename from ghost/core/test/unit/frontend/services/theme-engine/config.test.js rename to ghost/core/test/unit/frontend/services/theme-engine/config.test.ts index a68f468f104..06d0fa0250e 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/config.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/config.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const themeConfig = require('../../../../../core/frontend/services/theme-engine/config'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import themeConfig from '../../../../../core/frontend/services/theme-engine/config'; describe('Themes', function () { afterEach(function () { diff --git a/ghost/core/test/unit/frontend/utils/frontend-apps.test.js b/ghost/core/test/unit/frontend/utils/frontend-apps.test.ts similarity index 83% rename from ghost/core/test/unit/frontend/utils/frontend-apps.test.js rename to ghost/core/test/unit/frontend/utils/frontend-apps.test.ts index ee3a241a97d..7efe6079d72 100644 --- a/ghost/core/test/unit/frontend/utils/frontend-apps.test.js +++ b/ghost/core/test/unit/frontend/utils/frontend-apps.test.ts @@ -1,9 +1,11 @@ -const assert = require('node:assert/strict'); -const { +import assert from 'node:assert/strict'; +import { getFrontendAppConfig, getDataAttributes, -} = require('../../../../core/frontend/utils/frontend-apps'); -const configUtils = require('../../../utils/config-utils'); + // @ts-expect-error This module lacks type definitions. +} from '../../../../core/frontend/utils/frontend-apps'; +// @ts-expect-error This module lacks type definitions. +import configUtils from '../../../utils/config-utils'; describe('Frontend apps:', function () { describe('getFrontendAppConfig', function () { diff --git a/ghost/core/test/unit/frontend/utils/member-count.test.js b/ghost/core/test/unit/frontend/utils/member-count.test.ts similarity index 82% rename from ghost/core/test/unit/frontend/utils/member-count.test.js rename to ghost/core/test/unit/frontend/utils/member-count.test.ts index 34badcaa2b0..cb7b1be3ce6 100644 --- a/ghost/core/test/unit/frontend/utils/member-count.test.js +++ b/ghost/core/test/unit/frontend/utils/member-count.test.ts @@ -1,8 +1,6 @@ -const assert = require('node:assert/strict'); -const { - memberCountRounding, - getMemberStats, -} = require('../../../../core/frontend/utils/member-count'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import { memberCountRounding, getMemberStats } from '../../../../core/frontend/utils/member-count'; const getMemberStatsMock = [ { diff --git a/ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.js b/ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.ts similarity index 83% rename from ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.js rename to ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.ts index 918393c8227..77a83a42dcd 100644 --- a/ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.js +++ b/ghost/core/test/unit/server/adapters/lib/redis/redis-store-factory.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const redisStoreFactory = require('../../../../../../core/server/adapters/lib/redis/redis-store-factory'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import redisStoreFactory from '../../../../../../core/server/adapters/lib/redis/redis-store-factory'; class CacheManagerMock { static create() { diff --git a/ghost/core/test/unit/server/data/importer/import-manager.test.js b/ghost/core/test/unit/server/data/importer/import-manager.test.ts similarity index 73% rename from ghost/core/test/unit/server/data/importer/import-manager.test.js rename to ghost/core/test/unit/server/data/importer/import-manager.test.ts index e74f12d0708..64a65e36f9a 100644 --- a/ghost/core/test/unit/server/data/importer/import-manager.test.js +++ b/ghost/core/test/unit/server/data/importer/import-manager.test.ts @@ -1,8 +1,9 @@ -const assert = require('node:assert/strict'); -const fs = require('fs-extra'); -const path = require('path'); -const { globSync } = require('glob'); -const importManager = require('../../../../../core/server/data/importer/import-manager'); +import assert from 'node:assert/strict'; +import fs from 'fs-extra'; +import path from 'path'; +import { globSync } from 'glob'; +// @ts-expect-error This module lacks type definitions. +import importManager from '../../../../../core/server/data/importer/import-manager'; describe('Import Manager', function () { describe('extractZip', function () { diff --git a/ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.js b/ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.ts similarity index 87% rename from ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.js rename to ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.ts index 2e30024b65b..be69a168ef2 100644 --- a/ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.js +++ b/ghost/core/test/unit/server/data/importer/importers/data/newsletters.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const NewslettersImporter = require('../../../../../../../core/server/data/importer/importers/data/newsletters-importer'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import NewslettersImporter from '../../../../../../../core/server/data/importer/importers/data/newsletters-importer'; const fakeNewsletters = [ { diff --git a/ghost/core/test/unit/server/data/importer/importers/data/posts.test.js b/ghost/core/test/unit/server/data/importer/importers/data/posts.test.ts similarity index 94% rename from ghost/core/test/unit/server/data/importer/importers/data/posts.test.js rename to ghost/core/test/unit/server/data/importer/importers/data/posts.test.ts index 37944166905..5c6fcc3dc55 100644 --- a/ghost/core/test/unit/server/data/importer/importers/data/posts.test.js +++ b/ghost/core/test/unit/server/data/importer/importers/data/posts.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../../../utils/assertions'); -const find = require('lodash/find'); -const PostsImporter = require('../../../../../../../core/server/data/importer/importers/data/posts-importer'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../../../utils/assertions'; +import find from 'lodash/find'; +// @ts-expect-error This module lacks type definitions. +import PostsImporter from '../../../../../../../core/server/data/importer/importers/data/posts-importer'; describe('PostsImporter', function () { describe('#beforeImport', function () { diff --git a/ghost/core/test/unit/server/data/importer/importers/data/products.test.js b/ghost/core/test/unit/server/data/importer/importers/data/products.test.ts similarity index 94% rename from ghost/core/test/unit/server/data/importer/importers/data/products.test.js rename to ghost/core/test/unit/server/data/importer/importers/data/products.test.ts index d6800d60901..14c44867ccb 100644 --- a/ghost/core/test/unit/server/data/importer/importers/data/products.test.js +++ b/ghost/core/test/unit/server/data/importer/importers/data/products.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const ProductsImporter = require('../../../../../../../core/server/data/importer/importers/data/products-importer'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import ProductsImporter from '../../../../../../../core/server/data/importer/importers/data/products-importer'; const fakeProducts = [ { diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.ts similarity index 82% rename from ghost/core/test/unit/server/data/schema/integrity.test.js rename to ghost/core/test/unit/server/data/schema/integrity.test.ts index e094c55aac7..d70efdb1f91 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.ts @@ -1,16 +1,19 @@ -const assert = require('node:assert/strict'); -const _ = require('lodash'); -const crypto = require('crypto'); -const fs = require('fs-extra'); -const path = require('path'); -const { config } = require('../../../../utils/config-utils'); -const schema = require('../../../../../core/server/data/schema/schema'); -const inDevelopment = require('../../../../../core/server/data/schema/in-development'); -const fixtures = require('../../../../../core/server/data/schema/fixtures/fixtures.json'); -const defaultSettings = require('../../../../../core/server/data/schema/default-settings/default-settings.json'); +import assert from 'node:assert/strict'; +import _ from 'lodash'; +import crypto from 'crypto'; +import fs from 'fs-extra'; +import path from 'path'; +// @ts-expect-error This module lacks type definitions. +import { config } from '../../../../utils/config-utils'; +// @ts-expect-error This module lacks type definitions. +import schema from '../../../../../core/server/data/schema/schema'; +import * as inDevelopment from '../../../../../core/server/data/schema/in-development'; +import fixtures from '../../../../../core/server/data/schema/fixtures/fixtures.json'; +import defaultSettings from '../../../../../core/server/data/schema/default-settings/default-settings.json'; // Routes are yaml so we can require the file directly -const routeSettings = require('../../../../../core/server/services/route-settings'); +// @ts-expect-error This module lacks type definitions. +import routeSettings from '../../../../../core/server/services/route-settings'; routeSettings.init(); const { parseRouteSettings, diff --git a/ghost/core/test/unit/server/lib/file-types.test.js b/ghost/core/test/unit/server/lib/file-types.test.ts similarity index 97% rename from ghost/core/test/unit/server/lib/file-types.test.js rename to ghost/core/test/unit/server/lib/file-types.test.ts index 01fc4dfd0aa..7538c7caa01 100644 --- a/ghost/core/test/unit/server/lib/file-types.test.js +++ b/ghost/core/test/unit/server/lib/file-types.test.ts @@ -1,5 +1,5 @@ -const assert = require('node:assert/strict'); -const { getStorageContentType } = require('../../../../core/server/lib/file-types'); +import assert from 'node:assert/strict'; +import { getStorageContentType } from '../../../../core/server/lib/file-types'; describe('file-types', function () { describe('getStorageContentType', function () { diff --git a/ghost/core/test/unit/server/lib/lexical.test.js b/ghost/core/test/unit/server/lib/lexical.test.ts similarity index 94% rename from ghost/core/test/unit/server/lib/lexical.test.js rename to ghost/core/test/unit/server/lib/lexical.test.ts index a22c4dbaff4..b3645d381d1 100644 --- a/ghost/core/test/unit/server/lib/lexical.test.js +++ b/ghost/core/test/unit/server/lib/lexical.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const jsdom = require('jsdom'); -const lexicalLib = require('../../../../core/server/lib/lexical'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import jsdom from 'jsdom'; +// @ts-expect-error This module lacks type definitions. +import lexicalLib from '../../../../core/server/lib/lexical'; describe('lib/lexical', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/lib/package-json/filter.test.js b/ghost/core/test/unit/server/lib/package-json/filter.test.ts similarity index 97% rename from ghost/core/test/unit/server/lib/package-json/filter.test.js rename to ghost/core/test/unit/server/lib/package-json/filter.test.ts index b20fc38e5fb..2f9b3bce5cb 100644 --- a/ghost/core/test/unit/server/lib/package-json/filter.test.js +++ b/ghost/core/test/unit/server/lib/package-json/filter.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const packageJSON = require('../../../../../core/server/lib/package-json'); +// @ts-expect-error This module lacks type definitions. +import packageJSON from '../../../../../core/server/lib/package-json'; describe('package-json filter', function () { // @TODO: introduce some non-theme package examples diff --git a/ghost/core/test/unit/server/lib/package-json/read.test.js b/ghost/core/test/unit/server/lib/package-json/read.test.ts similarity index 97% rename from ghost/core/test/unit/server/lib/package-json/read.test.js rename to ghost/core/test/unit/server/lib/package-json/read.test.ts index de541a326cc..313cf3b71ab 100644 --- a/ghost/core/test/unit/server/lib/package-json/read.test.js +++ b/ghost/core/test/unit/server/lib/package-json/read.test.ts @@ -1,9 +1,11 @@ -const assert = require('node:assert/strict'); - -const tmp = require('tmp'); -const join = require('path').join; -const fs = require('fs-extra'); -const packageJSON = require('../../../../../core/server/lib/package-json'); +import assert from 'node:assert/strict'; + +// @ts-expect-error This module lacks type definitions. +import tmp from 'tmp'; +import { join } from 'path'; +import fs from 'fs-extra'; +// @ts-expect-error This module lacks type definitions. +import packageJSON from '../../../../../core/server/lib/package-json'; describe('package-json read', function () { describe('readPackages', function () { diff --git a/ghost/core/test/unit/server/services/auth/setup.test.js b/ghost/core/test/unit/server/services/auth/setup.test.ts similarity index 90% rename from ghost/core/test/unit/server/services/auth/setup.test.js rename to ghost/core/test/unit/server/services/auth/setup.test.ts index 449a8249680..bf994084af8 100644 --- a/ghost/core/test/unit/server/services/auth/setup.test.js +++ b/ghost/core/test/unit/server/services/auth/setup.test.ts @@ -1,7 +1,6 @@ -const { - doProductAndNewsletter: doTierAndNewsletter, -} = require('../../../../../core/server/services/auth/setup'); -const sinon = require('sinon'); +// @ts-expect-error This module lacks type definitions. +import { doProductAndNewsletter as doTierAndNewsletter } from '../../../../../core/server/services/auth/setup'; +import sinon from 'sinon'; describe('Auth Service: setup', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/services/automations/automation-entry-stats.test.ts b/ghost/core/test/unit/server/services/automations/automation-entry-stats.test.ts new file mode 100644 index 00000000000..d2892e8d7d6 --- /dev/null +++ b/ghost/core/test/unit/server/services/automations/automation-entry-stats.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { + getEntryStatsWindow, + parseEntryStatsTimezone, +} from '../../../../../core/server/services/automations/automation-entry-stats'; + +describe('automation entry history', function () { + it('derives the window from the returned calendar with an exclusive end', function () { + assert.deepEqual( + getEntryStatsWindow( + [ + { date: '2020-01-01', count: 2 }, + { date: '2026-01-01', count: 0 }, + ], + 'America/New_York', + ), + { + date_from: '2020-01-01', + date_to: '2026-01-02', + bucket: 'day', + timezone: 'America/New_York', + }, + ); + }); + + it('keeps a single zero bucket as a one-day window', function () { + assert.deepEqual(getEntryStatsWindow([{ date: '2024-02-29', count: 0 }]), { + date_from: '2024-02-29', + date_to: '2024-03-01', + bucket: 'day', + timezone: 'UTC', + }); + }); +}); + +describe('automation statistics timezone', function () { + it('defaults to UTC and normalizes a supplied timezone', function () { + assert.equal(parseEntryStatsTimezone(undefined), 'UTC'); + assert.equal(parseEntryStatsTimezone('america/new_york'), 'America/New_York'); + }); + + it.each(['invalid', '', null, 123])('rejects invalid timezone %s', function (timezone) { + assert.throws( + () => parseEntryStatsTimezone(timezone), + /Invalid automation statistics timezone/, + ); + }); +}); diff --git a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts index bef87f2b2fb..a25de92d3a0 100644 --- a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts @@ -12,6 +12,7 @@ import type { AutomationAction, AutomationsRepository, AutomationStepToRun, + AutomationTriggerTierScope, } from '../../../../../core/server/services/automations/automations-repository'; import { fromDatabaseDate, toDatabaseDate } from '../../../../../core/server/lib/db-types/date'; @@ -225,6 +226,7 @@ const createDatabase = async (): Promise => { slug: 'member-welcome-email-free', name: 'Free member welcome flow', description: 'Welcome new free members after they sign up.', + trigger_tier_scope: 'free', status: 'active', }, { @@ -234,6 +236,7 @@ const createDatabase = async (): Promise => { slug: 'member-welcome-email-paid', name: 'Paid member welcome flow', description: 'Welcome new paid members after they start their subscription.', + trigger_tier_scope: 'all_paid', status: 'active', }, ]); @@ -432,12 +435,11 @@ describe('automations repository', function () { let knex: Knex; let repo: AutomationsRepository; - const getRunByMemberEmail = async (email: string): Promise => + const getRunsByMemberEmail = async (email: string): Promise => await knex('automation_runs') .select('automation_runs.*', 'automations.slug as automation_slug') .innerJoin('automations', 'automations.id', 'automation_runs.automation_id') - .where('automation_runs.member_email', email) - .first(); + .where('automation_runs.member_email', email); const getStepByRunId = async (runId: string) => await knex('automation_run_steps') @@ -533,6 +535,44 @@ describe('automations repository', function () { return result; }; + const insertAutomation = async ({ + slug, + triggerTierScope, + }: { + slug: string; + triggerTierScope: null | AutomationTriggerTierScope; + }) => { + const automationId = ObjectId().toHexString(); + const actionId = ObjectId().toHexString(); + const now = toDatabaseDate(new Date()); + + await knex('automations').insert({ + id: automationId, + created_at: now, + updated_at: now, + slug, + name: slug, + description: slug, + status: 'active', + trigger_tier_scope: triggerTierScope, + }); + await knex('automation_actions').insert({ + id: actionId, + created_at: now, + updated_at: now, + automation_id: automationId, + type: 'wait', + }); + await knex('automation_action_revisions').insert({ + id: ObjectId().toHexString(), + created_at: now, + action_id: actionId, + wait_hours: 24, + }); + + return automationId; + }; + const insertRun = async (automationId: string, createdAt = new Date()) => { const run = { id: ObjectId().toHexString(), @@ -1241,7 +1281,9 @@ describe('automations repository', function () { memberStatus: 'free', }); - const run = await getRunByMemberEmail('free@example.com'); + const runs = await getRunsByMemberEmail('free@example.com'); + const [run] = runs; + assert.equal(runs.length, 1); assert(run); assert.equal(run.member_email, 'free@example.com'); assert.equal(run.member_id, 'member_123'); @@ -1264,6 +1306,25 @@ describe('automations repository', function () { assert.equal(step.locked_at, null); }); + it('can trigger multiple automations for a free signup', async function () { + await insertAutomation({ + slug: 'member-welcome-email-free-second', + triggerTierScope: 'free', + }); + + await repo.trigger({ + memberEmail: 'free-multiple@example.com', + memberId: 'member_123', + memberStatus: 'free', + }); + + const runs = await getRunsByMemberEmail('free-multiple@example.com'); + assert.deepEqual(runs.map((run) => run.automation_slug).sort(), [ + 'member-welcome-email-free', + 'member-welcome-email-free-second', + ]); + }); + it('uses the fake wait hours multiplier for triggered wait actions when configured', async function () { repo = createDatabaseAutomationsRepository({ knex, @@ -1278,7 +1339,9 @@ describe('automations repository', function () { }); const afterTrigger = Date.now(); - const run = await getRunByMemberEmail('fake-wait@example.com'); + const runs = await getRunsByMemberEmail('fake-wait@example.com'); + const [run] = runs; + assert.equal(runs.length, 1); assert(run); const step = await getStepByRunId(run.id); @@ -1295,7 +1358,9 @@ describe('automations repository', function () { memberStatus: 'paid', }); - const run = await getRunByMemberEmail('paid@example.com'); + const runs = await getRunsByMemberEmail('paid@example.com'); + const [run] = runs; + assert.equal(runs.length, 1); assert(run); assert.equal(run.automation_slug, 'member-welcome-email-paid'); @@ -1305,6 +1370,25 @@ describe('automations repository', function () { assert.equal(step.action_type, 'wait'); }); + it('can trigger multiple automations for a paid signup', async function () { + await insertAutomation({ + slug: 'member-welcome-email-paid-second', + triggerTierScope: 'all_paid', + }); + + await repo.trigger({ + memberEmail: 'paid-multiple@example.com', + memberId: 'member_123', + memberStatus: 'paid', + }); + + const runs = await getRunsByMemberEmail('paid-multiple@example.com'); + assert.deepEqual(runs.map((run) => run.automation_slug).sort(), [ + 'member-welcome-email-paid', + 'member-welcome-email-paid-second', + ]); + }); + it('inserts the first non-deleted step', async function () { const automation = await getAutomationBySlug('member-welcome-email-free'); await repo.edit(automation.id, { @@ -1346,7 +1430,9 @@ describe('automations repository', function () { memberStatus: 'free', }); - const run = await getRunByMemberEmail('free@example.com'); + const runs = await getRunsByMemberEmail('free@example.com'); + const [run] = runs; + assert.equal(runs.length, 1); assert(run); const step = await getStepByRunId(run.id); @@ -1367,7 +1453,7 @@ describe('automations repository', function () { memberStatus: 'free', }); - assert.equal(await getRunByMemberEmail('inactive-free@example.com'), undefined); + assert.deepEqual(await getRunsByMemberEmail('inactive-free@example.com'), []); assert.equal(await getRunCountByAutomationId(freeAutomation.id), 0); }); @@ -1385,10 +1471,51 @@ describe('automations repository', function () { memberStatus: 'free', }); - assert.equal(await getRunByMemberEmail('free-no-actions@example.com'), undefined); + assert.deepEqual(await getRunsByMemberEmail('free-no-actions@example.com'), []); assert.equal(await getRunCountByAutomationId(freeAutomation.id), 0); }); + // TODO(NY-1643) This test will change when we add support for this trigger tier scope. + it('does not trigger an automation for the "selected_paid" trigger tier scope', async function () { + const automationId = await insertAutomation({ + slug: 'member-welcome-email-selected-paid', + triggerTierScope: 'selected_paid', + }); + + await repo.trigger({ + memberEmail: 'selected-paid-free@example.com', + memberId: 'member_123', + memberStatus: 'free', + }); + await repo.trigger({ + memberEmail: 'selected-paid-paid@example.com', + memberId: 'member_123', + memberStatus: 'paid', + }); + + assert.equal(await getRunCountByAutomationId(automationId), 0); + }); + + it('does not trigger an automation when there is no trigger tier scope', async function () { + const automationId = await insertAutomation({ + slug: 'member-welcome-email-without-scope', + triggerTierScope: null, + }); + + await repo.trigger({ + memberEmail: 'no-scope-free@example.com', + memberId: 'member_123', + memberStatus: 'free', + }); + await repo.trigger({ + memberEmail: 'no-scope-paid@example.com', + memberId: 'member_123', + memberStatus: 'paid', + }); + + assert.equal(await getRunCountByAutomationId(automationId), 0); + }); + it('does not enter the same automation twice for a member', async function () { const options = { memberEmail: 'free@example.com', @@ -1796,7 +1923,7 @@ describe('automations repository', function () { locked_by: assertSingleBatchLock(steps), automation_run_id: run.id, automation_id: automation.id, - automation_slug: automation.slug, + automation_trigger_tier_scope: 'free', automation_status: 'active', member_id: run.member_id, member_email: run.member_email, diff --git a/ghost/core/test/unit/server/services/automations/poll.test.ts b/ghost/core/test/unit/server/services/automations/poll.test.ts index 3159d35db01..c92f8298abb 100644 --- a/ghost/core/test/unit/server/services/automations/poll.test.ts +++ b/ghost/core/test/unit/server/services/automations/poll.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import sinon from 'sinon'; +import logging from '@tryghost/logging'; import { poll } from '../../../../../core/server/services/automations/poll'; import type { AutomationStepToRun } from '../../../../../core/server/services/automations/automations-repository'; -import { MEMBER_WELCOME_EMAIL_SLUGS } from '../../../../../core/server/services/member-welcome-emails/constants'; // @ts-expect-error Models currently lack type definitions. import { Member } from '../../../../../core/server/models'; @@ -93,7 +93,7 @@ function buildStep(attrs: Partial = {}): StepBase { locked_by: 'lock-id', automation_run_id: 'run-id', automation_id: 'automation-id', - automation_slug: MEMBER_WELCOME_EMAIL_SLUGS.free, + automation_trigger_tier_scope: 'free', automation_status: 'active', member_id: 'member-id', member_email: 'member@example.com', @@ -273,7 +273,7 @@ describe('automations poll', function () { ); }); - it('bails if the member status changed', async function () { + it('bails if the member status changed from free to paid', async function () { const step = buildEmailStep(); automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); Member.findOne.resolves(buildMember({ status: 'paid' })); @@ -288,6 +288,21 @@ describe('automations poll', function () { ); }); + it('bails if the member status changed paid to free', async function () { + const step = buildEmailStep({ automation_trigger_tier_scope: 'all_paid' }); + automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); + Member.findOne.resolves(buildMember({ status: 'free' })); + + await poll(options); + + sinon.assert.notCalled(memberWelcomeEmailService.api.sendAutomationEmail); + sinon.assert.calledOnceWithExactly( + automationsApi.markStepTerminal, + step, + 'member changed status', + ); + }); + it('ends the automation run if the member unsubscribed from updates & announcements', async function () { const step = buildEmailStep(); automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); @@ -350,7 +365,7 @@ describe('automations poll', function () { it('gift members run through paid automations', async function () { const step = buildEmailStep({ - automation_slug: MEMBER_WELCOME_EMAIL_SLUGS.paid, + automation_trigger_tier_scope: 'all_paid', }); automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); Member.findOne.resolves(buildMember({ status: 'gift' })); @@ -646,6 +661,57 @@ describe('automations poll', function () { sinon.assert.calledOnceWithExactly(options.enqueueAnotherPollAt, retryAt); }); + it('logs native send errors unchanged', async function () { + const step = buildEmailStep(); + const error = new Error('Mailgun request timed out'); + const logError = sinon.stub(logging, 'error'); + automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); + memberWelcomeEmailService.api.sendAutomationEmail.rejects(error); + + await poll(options); + + sinon.assert.calledOnceWithExactly( + logError, + { + err: sinon.match.same(error), + system: { + event: 'automations.poll.step_execution_failed', + step_id: step.id, + }, + }, + `[AUTOMATIONS] Failed to execute automation step ${step.id}`, + ); + sinon.assert.calledOnce(automationsApi.retryStep); + sinon.assert.notCalled(automationsApi.finishStepAndEnqueueNext); + }); + + it('logs the original Mailgun error without the email payload', async function () { + const step = buildEmailStep(); + const error = new Error('Mailgun request timed out'); + const logError = sinon.stub(logging, 'error'); + automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); + memberWelcomeEmailService.api.sendAutomationEmail.rejects({ + error, + messageData: { html: 'Private email content' }, + }); + + await poll(options); + + sinon.assert.calledOnceWithExactly( + logError, + { + err: sinon.match.same(error), + system: { + event: 'automations.poll.step_execution_failed', + step_id: step.id, + }, + }, + `[AUTOMATIONS] Failed to execute automation step ${step.id}`, + ); + sinon.assert.calledOnce(automationsApi.retryStep); + sinon.assert.notCalled(automationsApi.finishStepAndEnqueueNext); + }); + it('permanently fails email send failures at the attempt limit', async function () { const step = buildEmailStep({ step_attempts: 10 }); automationsApi.fetchAndLockSteps.resolves({ steps: [step], nextStepReadyAt: null }); diff --git a/ghost/core/test/unit/server/services/automations/tinybird-automation-stats.test.ts b/ghost/core/test/unit/server/services/automations/tinybird-automation-stats.test.ts index 4a089e8de62..05d1be615bc 100644 --- a/ghost/core/test/unit/server/services/automations/tinybird-automation-stats.test.ts +++ b/ghost/core/test/unit/server/services/automations/tinybird-automation-stats.test.ts @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import sinon from 'sinon'; import { afterEach, describe, it } from 'vitest'; import logging from '@tryghost/logging'; -import { fetchAutomationStats } from '../../../../../core/server/services/automations/tinybird-automation-stats'; +import { + fetchAutomationStats, + fetchAutomationPerformanceStats, +} from '../../../../../core/server/services/automations/tinybird-automation-stats'; const clientReturning = (value: unknown) => ({ fetch: sinon.stub().resolves(value) }); @@ -69,7 +72,7 @@ describe('fetchAutomationStats', function () { { total_run_count: null }, { total_run_count: -1 }, { in_progress_run_count: 1.5 }, - ])('omits invalid stats: %j', async function (overrides) { + ])('rejects invalid stats: %j', async function (overrides) { sinon.stub(logging, 'error'); const stats = await fetchAutomationStats( clientReturning([ @@ -85,3 +88,76 @@ describe('fetchAutomationStats', function () { assert.equal(stats, null); }); }); + +describe('fetchAutomationPerformanceStats', function () { + afterEach(() => sinon.restore()); + + const row = (date: string, completed: unknown = 1) => ({ + date, + in_progress_run_count: 0, + completed_run_count: completed, + exited_early_run_count: 0, + invalid_run_count: 0, + }); + + it('derives the ordered chart and all three totals from one query', async function () { + const client = clientReturning([ + { ...row('2026-09-14', '3'), in_progress_run_count: '2', exited_early_run_count: '6' }, + row('2020-01-01', 2), + ]); + assert.deepEqual(await fetchAutomationPerformanceStats(client, 'selected'), { + total_run_count: 13, + in_progress_run_count: 2, + completed_run_count: 5, + exited_early_run_count: 6, + entries: [ + { date: '2020-01-01', count: 2 }, + { date: '2026-09-14', count: 11 }, + ], + }); + sinon.assert.calledOnceWithExactly(client.fetch, 'api_automation_performance_stats', { + version: '', + automationId: 'selected', + timezone: 'UTC', + }); + }); + + it('returns zero for a successful empty history', async function () { + assert.deepEqual( + await fetchAutomationPerformanceStats(clientReturning([row('2026-09-14', 0)]), 'selected'), + { + total_run_count: 0, + in_progress_run_count: 0, + completed_run_count: 0, + exited_early_run_count: 0, + entries: [{ date: '2026-09-14', count: 0 }], + }, + ); + }); + + it.each([ + { name: 'missing response', rows: null }, + { name: 'missing calendar', rows: [] }, + { name: 'invalid date', rows: [row('2026-02-30')] }, + { name: 'negative count', rows: [row('2026-09-01', -1)] }, + { name: 'null count', rows: [row('2026-09-01', null)] }, + { name: 'fractional count', rows: [row('2026-09-01', 1.5)] }, + { name: 'duplicate day', rows: [row('2026-09-01'), row('2026-09-01', 2)] }, + { name: 'unsafe total', rows: [row('2026-09-01', Number.MAX_SAFE_INTEGER), row('2026-09-02')] }, + { name: 'unexpected step status', rows: [{ ...row('2026-09-01'), invalid_run_count: 1 }] }, + { + name: 'unexpected status alongside pending', + rows: [{ ...row('2026-09-01'), in_progress_run_count: 1, invalid_run_count: 1 }], + }, + ])('rejects and logs $name instead of serving partial statistics', async function ({ rows }) { + const error = sinon.stub(logging, 'error'); + assert.equal(await fetchAutomationPerformanceStats(clientReturning(rows), 'selected'), null); + sinon.assert.calledOnce(error); + }); + + it('returns null when Tinybird fails', async function () { + sinon.stub(logging, 'error'); + const client = { fetch: sinon.stub().rejects(new Error('unavailable')) }; + assert.equal(await fetchAutomationPerformanceStats(client, 'selected'), null); + }); +}); diff --git a/ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.js b/ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.ts similarity index 92% rename from ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.js rename to ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.ts index 49b75dfd335..5bb56b034a6 100644 --- a/ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.js +++ b/ghost/core/test/unit/server/services/comments/comments-service-emails-renderer.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const i18nLib = require('@tryghost/i18n').default; -const CommentsServiceEmailRenderer = require('../../../../../core/server/services/comments/comments-service-email-renderer'); +import assert from 'node:assert/strict'; +import i18nLib from '@tryghost/i18n'; +// @ts-expect-error This module lacks type definitions. +import CommentsServiceEmailRenderer from '../../../../../core/server/services/comments/comments-service-email-renderer'; describe('Comments Service Email Renderer', function () { describe('renderEmail Template with different locales', function () { diff --git a/ghost/core/test/unit/server/services/custom-redirects/validation.test.js b/ghost/core/test/unit/server/services/custom-redirects/validation.test.ts similarity index 93% rename from ghost/core/test/unit/server/services/custom-redirects/validation.test.js rename to ghost/core/test/unit/server/services/custom-redirects/validation.test.ts index a48c2f18008..7e6fdba3c87 100644 --- a/ghost/core/test/unit/server/services/custom-redirects/validation.test.js +++ b/ghost/core/test/unit/server/services/custom-redirects/validation.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const { validate } = require('../../../../../core/server/services/custom-redirects/validation'); +// @ts-expect-error This module lacks type definitions. +import { validate } from '../../../../../core/server/services/custom-redirects/validation'; describe('custom redirects validation', function () { it('passes validation for a valid redirects config', function () { diff --git a/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts b/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts index 7ffa9997cab..ffe0b2f8a4b 100644 --- a/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts +++ b/ghost/core/test/unit/server/services/email-analytics/email-analytics-service.test.ts @@ -868,18 +868,62 @@ describe('EmailAnalyticsService', function () { assert.equal(result.storedPermanentFailed, 2); }); - it('preserves new email and member IDs in the cumulative result', async function () { + it('retains event counts without retaining processed IDs', async function () { const eventProcessor = createStubEventProcessor(); eventProcessor.processBatch.callsFake(async (_events, result) => { - result.emailIds.push('email-id'); - result.memberIds.push('member-id'); + result.merge({ opened: 1, emailIds: ['email-id'], memberIds: ['member-id'] }); }); const service = createServiceWithEventProcessor(eventProcessor); const result = await service.fetchLatestOpenedEvents(); - assert.deepEqual(result.result.emailIds, ['email-id']); - assert.deepEqual(result.result.memberIds, ['member-id']); + assert.equal(result.result.opened, 1); + assert.deepEqual(result.result.emailIds, []); + assert.deepEqual(result.result.memberIds, []); + }); + + it('accumulates event counts across intermediate resets', async function () { + const eventProcessor = createStubEventProcessor(); + const batches = [ + { opened: 1, emailIds: ['email-1'], memberIds: ['member-1', 'member-2'] }, + { opened: 2, emailIds: ['email-1', 'email-2'], memberIds: ['member-2', 'member-3'] }, + { opened: 3, emailIds: ['email-2'], memberIds: ['member-3'] }, + ]; + eventProcessor.processBatch.callsFake(async (_events, result) => { + result.merge(batches.shift()); + }); + // A processor resets the shared result after an intermediate aggregation, + // so count deltas start from both zero and non-zero values. + let intermediateAggregations = 0; + eventProcessor.aggregate.callsFake(async ({ processingResult, isFinal }) => { + if (!isFinal) { + intermediateAggregations += 1; + if (intermediateAggregations === 2) { + processingResult.reset(); + } + } + return { emailAggregationTimeMs: 0, memberAggregationTimeMs: 0 }; + }); + const service = createService({ + queries: { + getLastEventTimestamp: sinon.stub().resolves(), + setJobTimestamp: sinon.stub().resolves(), + setJobStatus: sinon.stub().resolves(), + }, + fetchEvents: async ({ batchHandler }: { batchHandler: BatchHandler }) => { + for (let i = 0; i < 3; i++) { + await batchHandler([{ type: 'delivered', timestamp: new Date(i + 1) }]); + } + }, + createEventProcessor: () => eventProcessor, + }); + + const result = await service.fetchLatestOpenedEvents(); + + assert.equal(result.eventCount, 3); + assert.equal(result.result.opened, 6); + assert.deepEqual(result.result.emailIds, []); + assert.deepEqual(result.result.memberIds, []); }); it('rejects when fetching events fails', async function () { diff --git a/ghost/core/test/unit/server/services/email-analytics/event-processing-result.test.ts b/ghost/core/test/unit/server/services/email-analytics/event-processing-result.test.ts index 2e36b4b10c1..ff5c717f5f1 100644 --- a/ghost/core/test/unit/server/services/email-analytics/event-processing-result.test.ts +++ b/ghost/core/test/unit/server/services/email-analytics/event-processing-result.test.ts @@ -69,9 +69,73 @@ describe('EventProcessingResult', function () { result.reset(); assert.deepEqual(result, new EventProcessingResult()); + assert.deepEqual(result.emailIds, []); + assert.deepEqual(result.memberIds, []); }); describe('merge()', function () { + it('ignores empty IDs and preserves first-seen order across repeated merges', function () { + const result = new EventProcessingResult({ + emailIds: ['', 'email-2', 'email-2', 'email-1'], + memberIds: ['member-2', '', 'member-1', 'member-2'], + }); + + for (let i = 0; i < 3; i++) { + result.merge({ + opened: 1, + emailIds: ['email-1', '', 'email-3', 'email-2'], + memberIds: ['', 'member-3', 'member-1', 'member-2'], + }); + } + + assert.equal(result.opened, 3); + assert.deepEqual(result.emailIds, ['email-2', 'email-1', 'email-3']); + assert.deepEqual(result.memberIds, ['member-2', 'member-1', 'member-3']); + }); + + it('can collect the same IDs again after reset', function () { + const result = new EventProcessingResult({ + opened: 2, + emailIds: ['email-1', 'email-2'], + memberIds: ['member-1', 'member-2'], + }); + + result.reset(); + result.merge({ + opened: 1, + emailIds: ['email-2', 'email-2', 'email-1'], + memberIds: ['member-2', 'member-2', 'member-1'], + }); + + assert.equal(result.opened, 1); + assert.deepEqual(result.emailIds, ['email-2', 'email-1']); + assert.deepEqual(result.memberIds, ['member-2', 'member-1']); + }); + + it('keeps merged results independent when the source resets and is reused', function () { + const page = new EventProcessingResult({ + opened: 2, + emailIds: ['email-1'], + memberIds: ['member-1', 'member-2'], + }); + const total = new EventProcessingResult(page); + + page.reset(); + page.merge({ + opened: 2, + emailIds: ['email-1', 'email-2'], + memberIds: ['member-2', 'member-3'], + }); + total.merge(page); + page.reset(); + + assert.equal(total.opened, 4); + assert.deepEqual(total.emailIds, ['email-1', 'email-2']); + assert.deepEqual(total.memberIds, ['member-1', 'member-2', 'member-3']); + assert.deepEqual(page.emailIds, []); + assert.deepEqual(page.memberIds, []); + }); + it('adds counts and merges id arrays', function () { const result = new EventProcessingResult({ delivered: 1, diff --git a/ghost/core/test/unit/server/services/lib/mailgun-error.test.ts b/ghost/core/test/unit/server/services/lib/mailgun-error.test.ts new file mode 100644 index 00000000000..e250873c71b --- /dev/null +++ b/ghost/core/test/unit/server/services/lib/mailgun-error.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; + +import { getMailgunError } from '../../../../../core/server/services/lib/mailgun-error'; + +describe('getMailgunError', function () { + it('returns the original error from a Mailgun rejection', function () { + const error = new Error('Mailgun request timed out'); + const rejection = { error, messageData: { to: 'member@example.com' } }; + + assert.equal(getMailgunError(rejection), error); + }); + + it('returns native errors unchanged', function () { + const error = new Error('Request failed'); + + assert.equal(getMailgunError(error), error); + }); + + it('returns objects without an Error-valued error property unchanged', function () { + for (const value of [ + {}, + { messageData: {} }, + { error: 'Request failed' }, + { error: null }, + { error: { message: 'Request failed' } }, + ]) { + assert.equal(getMailgunError(value), value); + } + }); + + it('returns non-object values unchanged', function () { + for (const value of [null, undefined, 'Request failed', 42, true, Symbol('error'), () => {}]) { + assert.equal(getMailgunError(value), value); + } + }); +}); diff --git a/ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.js b/ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.ts similarity index 95% rename from ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.js rename to ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.ts index 2a07f185f15..6cfec115e18 100644 --- a/ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.js +++ b/ghost/core/test/unit/server/services/machine-payments/deposit-address-store.test.ts @@ -1,8 +1,6 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const { - DepositAddressStore, -} = require('../../../../../core/server/services/machine-payments/stripe/deposit-address-store'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import { DepositAddressStore } from '../../../../../core/server/services/machine-payments/stripe/deposit-address-store'; describe('Unit: server/services/machine-payments/deposit-address-store', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.js b/ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.ts similarity index 97% rename from ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.js rename to ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.ts index 9ee605dcc44..79f7d4d97cc 100644 --- a/ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/controllers/member-controller.test.ts @@ -1,5 +1,6 @@ -const sinon = require('sinon'); -const MemberController = require('../../../../../../../core/server/services/members/members-api/controllers/member-controller'); +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import MemberController from '../../../../../../../core/server/services/members/members-api/controllers/member-controller'; describe('MemberController', function () { describe('updateSubscription', function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.ts similarity index 80% rename from ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.js rename to ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.ts index bba3a44de40..e4d9fdb1aa6 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/product-repository.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const ProductRepository = require('../../../../../../../core/server/services/members/members-api/repositories/product-repository'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import ProductRepository from '../../../../../../../core/server/services/members/members-api/repositories/product-repository'; describe('MemberRepository', function () { describe('getDefaultProduct', function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js b/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.ts similarity index 90% rename from ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js rename to ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.ts index a68f1d58855..f0324c87f12 100644 --- a/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../../../utils/assertions'); -const nock = require('nock'); -const GeolocationService = require('../../../../../../../core/server/services/members/members-api/services/geolocation-service'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../../../utils/assertions'; +import nock from 'nock'; +// @ts-expect-error This module lacks type definitions. +import GeolocationService from '../../../../../../../core/server/services/members/members-api/services/geolocation-service'; const GEOJS_RESPONSE = { longitude: '-2.2417', diff --git a/ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.js b/ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.ts similarity index 90% rename from ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.js rename to ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.ts index f3ed5baca7b..eb15d265c70 100644 --- a/ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.js +++ b/ghost/core/test/unit/server/services/members/request-integrity-token-provider.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; -const RequestIntegrityTokenProvider = require('../../../../../core/server/services/members/request-integrity-token-provider'); +// @ts-expect-error This module lacks type definitions. +import RequestIntegrityTokenProvider from '../../../../../core/server/services/members/request-integrity-token-provider'; const tokenProvider = new RequestIntegrityTokenProvider({ themeSecret: 'test', diff --git a/ghost/core/test/unit/server/services/members/utils.test.js b/ghost/core/test/unit/server/services/members/utils.test.ts similarity index 92% rename from ghost/core/test/unit/server/services/members/utils.test.js rename to ghost/core/test/unit/server/services/members/utils.test.ts index 2dbad305190..e02d4e832dc 100644 --- a/ghost/core/test/unit/server/services/members/utils.test.js +++ b/ghost/core/test/unit/server/services/members/utils.test.ts @@ -1,7 +1,9 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const { formattedMemberResponse } = require('../../../../../core/server/services/members/utils'); -const labs = require('../../../../../core/shared/labs'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import { formattedMemberResponse } from '../../../../../core/server/services/members/utils'; +// @ts-expect-error This module lacks type definitions. +import labs from '../../../../../core/shared/labs'; describe('Members Service - utils', function () { describe('formattedMemberResponse', function () { diff --git a/ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.js b/ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.ts similarity index 98% rename from ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.js rename to ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.ts index 612242d332e..546d326eb0b 100644 --- a/ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.js +++ b/ghost/core/test/unit/server/services/mentions/in-memory-mention-repository.test.ts @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; const ObjectID = require('bson-objectid').default; const InMemoryMentionRepository = require('../../../../../core/server/services/mentions/in-memory-mention-repository'); const Mention = require('../../../../../core/server/services/mentions/mention'); diff --git a/ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.js b/ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.ts similarity index 94% rename from ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.js rename to ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.ts index 1d6a9477704..5d980f9ce0e 100644 --- a/ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.js +++ b/ghost/core/test/unit/server/services/mentions/mention-discovery-service.test.ts @@ -1,12 +1,14 @@ -const sinon = require('sinon'); +import sinon from 'sinon'; -const assert = require('node:assert/strict'); -const nock = require('nock'); +import assert from 'node:assert/strict'; +import nock from 'nock'; // non-standard to use externalRequest here, but this is required for the overrides in the library, which we want to test for security reasons in combination with the package -const externalRequest = require('../../../../../core/server/lib/request-external.js'); +// @ts-expect-error This module lacks type definitions. +import externalRequest from '../../../../../core/server/lib/request-external.js'; -const MentionDiscoveryService = require('../../../../../core/server/services/mentions/mention-discovery-service'); +// @ts-expect-error This module lacks type definitions. +import MentionDiscoveryService from '../../../../../core/server/services/mentions/mention-discovery-service'; describe('MentionDiscoveryService', function () { const service = new MentionDiscoveryService({ externalRequest }); diff --git a/ghost/core/test/unit/server/services/mentions/mention.test.js b/ghost/core/test/unit/server/services/mentions/mention.test.ts similarity index 99% rename from ghost/core/test/unit/server/services/mentions/mention.test.js rename to ghost/core/test/unit/server/services/mentions/mention.test.ts index c8b5b38f968..c1bbf5c737e 100644 --- a/ghost/core/test/unit/server/services/mentions/mention.test.js +++ b/ghost/core/test/unit/server/services/mentions/mention.test.ts @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; const ObjectID = require('bson-objectid').default; const cheerio = require('cheerio/slim'); const sinon = require('sinon'); diff --git a/ghost/core/test/unit/server/services/mentions/resource-service.test.js b/ghost/core/test/unit/server/services/mentions/resource-service.test.ts similarity index 91% rename from ghost/core/test/unit/server/services/mentions/resource-service.test.js rename to ghost/core/test/unit/server/services/mentions/resource-service.test.ts index 470fe85059f..579da64b3a7 100644 --- a/ghost/core/test/unit/server/services/mentions/resource-service.test.js +++ b/ghost/core/test/unit/server/services/mentions/resource-service.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const ResourceService = require('../../../../../core/server/services/mentions/resource-service'); -const UrlUtils = require('@tryghost/url-utils'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import ResourceService from '../../../../../core/server/services/mentions/resource-service'; +import UrlUtils from '@tryghost/url-utils'; function buildUrlServiceWithStubbedResolve() { const resolveUrl = sinon.stub(); diff --git a/ghost/core/test/unit/server/services/mentions/routing-service.test.js b/ghost/core/test/unit/server/services/mentions/routing-service.test.ts similarity index 98% rename from ghost/core/test/unit/server/services/mentions/routing-service.test.js rename to ghost/core/test/unit/server/services/mentions/routing-service.test.ts index 7fa2338e726..ea4daa92b0c 100644 --- a/ghost/core/test/unit/server/services/mentions/routing-service.test.js +++ b/ghost/core/test/unit/server/services/mentions/routing-service.test.ts @@ -1,6 +1,6 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const nock = require('nock'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import nock from 'nock'; const ObjectID = require('bson-objectid').default; const externalRequest = require('../../../../../core/server/lib/request-external'); diff --git a/ghost/core/test/unit/server/services/milestones/milestone.test.js b/ghost/core/test/unit/server/services/milestones/milestone.test.ts similarity index 98% rename from ghost/core/test/unit/server/services/milestones/milestone.test.js rename to ghost/core/test/unit/server/services/milestones/milestone.test.ts index 76df04472b6..70b9a82e82d 100644 --- a/ghost/core/test/unit/server/services/milestones/milestone.test.js +++ b/ghost/core/test/unit/server/services/milestones/milestone.test.ts @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; const ObjectID = require('bson-objectid').default; const Milestone = require('../../../../../core/server/services/milestones/milestone'); diff --git a/ghost/core/test/unit/server/services/newsletters/index.test.js b/ghost/core/test/unit/server/services/newsletters/index.test.ts similarity index 91% rename from ghost/core/test/unit/server/services/newsletters/index.test.js rename to ghost/core/test/unit/server/services/newsletters/index.test.ts index 83d1687303e..62673f96b35 100644 --- a/ghost/core/test/unit/server/services/newsletters/index.test.js +++ b/ghost/core/test/unit/server/services/newsletters/index.test.ts @@ -1,4 +1,4 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; describe('Newsletters Service', function () { let newslettersService; diff --git a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js index 986ae4b31cf..a44be9145b4 100644 --- a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js +++ b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict'); const http = require('http'); const nock = require('nock'); const got = require('got').default; +const logging = require('@tryghost/logging'); const sinon = require('sinon'); const sharp = require('sharp'); const zlib = require('zlib'); @@ -618,6 +619,179 @@ describe('oembed-service', function () { await oembedService.fetchOembedDataFromUrl('https://youtube.com/live/1234?param=existing'); }); + describe('embed thumbnails', function () { + const thumbnailUrl = 'https://i.vimeocdn.com/video/123-d_640x360'; + + const mockVimeoOembed = () => { + nock('https://vimeo.com').get('/api/oembed.json').query(true).reply(200, { + type: 'video', + version: '1.0', + html: '', + width: 640, + height: 360, + thumbnail_url: thumbnailUrl, + thumbnail_width: 640, + thumbnail_height: 360, + }); + }; + + afterEach(function () { + sinon.restore(); + }); + + it('stores the thumbnail and returns the provider URL alongside', async function () { + const processImageFromUrlStub = sinon + .stub(oembedService, 'processImageFromUrl') + .resolves('/content/images/thumbnail/vimeo.jpg'); + mockVimeoOembed(); + + const response = await oembedService.fetchOembedDataFromUrl('https://vimeo.com/123'); + + assert.equal(response.thumbnail_url, '/content/images/thumbnail/vimeo.jpg'); + assert.equal(response.thumbnail_url_original, thumbnailUrl); + assert.equal(response.thumbnail_width, 640); + assert.equal(response.thumbnail_height, 360); + sinon.assert.calledOnceWithExactly(processImageFromUrlStub, thumbnailUrl, 'thumbnail'); + }); + + it('keeps the provider thumbnail when it cannot be stored', async function () { + sinon.stub(oembedService, 'processImageFromUrl').rejects(new Error('fetch failed')); + const loggingStub = sinon.stub(logging, 'error'); + mockVimeoOembed(); + + const response = await oembedService.fetchOembedDataFromUrl('https://vimeo.com/123'); + + assert.equal(response.thumbnail_url, thumbnailUrl); + assert.equal(response.thumbnail_url_original, thumbnailUrl); + assert.equal(response.thumbnail_width, 640); + assert.equal(response.thumbnail_height, 360); + sinon.assert.calledOnce(loggingStub); + }); + + describe('YouTube', function () { + const hqThumbnailUrl = 'https://i.ytimg.com/vi/1234/hqdefault.jpg'; + let service; + let saveRaw; + let jpegBytes; + + beforeAll(async function () { + jpegBytes = await sharp({ + create: { width: 1, height: 1, channels: 3, background: 'red' }, + }) + .jpeg() + .toBuffer(); + }); + + beforeEach(function () { + saveRaw = sinon.stub().callsFake(async (buffer, targetPath) => { + return `/content/images/${targetPath}`; + }); + service = new OembedService({ + config: ghostConfig, + externalRequest: requestExternal, + imageStore: { getSanitizedFileName: (name) => name, saveRaw }, + }); + + nock('https://www.youtube.com').get('/oembed').query(true).reply(200, { + type: 'video', + version: '1.0', + html: '', + width: 200, + height: 113, + thumbnail_url: hqThumbnailUrl, + thumbnail_width: 480, + thumbnail_height: 360, + }); + }); + + it('stores the un-letterboxed max resolution thumbnail', async function () { + nock('https://i.ytimg.com').get('/vi/1234/maxresdefault.jpg').reply(200, jpegBytes); + + const response = await service.fetchOembedDataFromUrl( + 'https://www.youtube.com/watch?v=1234', + ); + + assert.match( + response.thumbnail_url, + /^\/content\/images\/thumbnail\/maxresdefault-.+\.jpg$/, + ); + assert.equal(response.thumbnail_width, 1280); + assert.equal(response.thumbnail_height, 720); + assert.equal(response.thumbnail_url_original, hqThumbnailUrl); + sinon.assert.calledOnce(saveRaw); + }); + + it('falls back to the provider thumbnail when there is no max resolution thumbnail', async function () { + const loggingStub = sinon.stub(logging, 'error'); + nock('https://i.ytimg.com') + .get('/vi/1234/maxresdefault.jpg') + .reply(404, jpegBytes) + .get('/vi/1234/hqdefault.jpg') + .reply(200, jpegBytes); + + const response = await service.fetchOembedDataFromUrl( + 'https://www.youtube.com/watch?v=1234', + ); + + assert.match(response.thumbnail_url, /^\/content\/images\/thumbnail\/hqdefault-.+\.jpg$/); + assert.equal(response.thumbnail_width, 480); + assert.equal(response.thumbnail_height, 360); + assert.equal(response.thumbnail_url_original, hqThumbnailUrl); + sinon.assert.calledOnce(saveRaw); + sinon.assert.notCalled(loggingStub); + }); + }); + + it('leaves embeds without a thumbnail unchanged', async function () { + const processImageFromUrlStub = sinon.stub(oembedService, 'processImageFromUrl'); + + nock('https://www.youtube.com').get('/oembed').query(true).reply(200, { + type: 'video', + version: '1.0', + html: '', + width: 200, + height: 113, + }); + + const response = await oembedService.fetchOembedDataFromUrl( + 'https://www.youtube.com/watch?v=1234', + ); + + assert.equal('thumbnail_url' in response, false); + assert.equal('thumbnail_url_original' in response, false); + sinon.assert.notCalled(processImageFromUrlStub); + }); + + it('does not store thumbnails for mentions', async function () { + const service = new OembedService({ + config: { + get() { + return true; + }, + }, + externalRequest: requestExternal, + }); + service.registerProvider({ + async canSupportRequest() { + return true; + }, + async getOEmbedData() { + return { type: 'video', version: '1.0', thumbnail_url: thumbnailUrl }; + }, + }); + const processImageFromUrlStub = sinon.stub(service, 'processImageFromUrl'); + + const response = await service.fetchOembedDataFromUrl( + 'https://www.example.com/video', + 'mention', + ); + + assert.equal(response.thumbnail_url, thumbnailUrl); + assert.equal('thumbnail_url_original' in response, false); + sinon.assert.notCalled(processImageFromUrlStub); + }); + }); + it('keeps unknown provider fallback by default when the page fetch fails', async function () { const fetchError = new Error('Service Unavailable'); fetchError.response = { diff --git a/ghost/core/test/unit/server/services/offers/application/unique-checker.test.js b/ghost/core/test/unit/server/services/offers/application/unique-checker.test.ts similarity index 88% rename from ghost/core/test/unit/server/services/offers/application/unique-checker.test.js rename to ghost/core/test/unit/server/services/offers/application/unique-checker.test.ts index b506cc858a6..6313ce0797b 100644 --- a/ghost/core/test/unit/server/services/offers/application/unique-checker.test.js +++ b/ghost/core/test/unit/server/services/offers/application/unique-checker.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const UniqueChecker = require('../../../../../../core/server/services/offers/application/unique-checker'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import UniqueChecker from '../../../../../../core/server/services/offers/application/unique-checker'; describe('UniqueChecker', function () { describe('#isUniqueCode', function () { diff --git a/ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.js b/ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.ts similarity index 96% rename from ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.js rename to ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.ts index cbca443b6c2..9c379021b9a 100644 --- a/ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.js +++ b/ghost/core/test/unit/server/services/offers/domain/models/offer-amount.test.ts @@ -1,10 +1,11 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const { +import { OfferPercentageAmount, OfferFixedAmount, OfferTrialAmount, -} = require('../../../../../../../core/server/services/offers/domain/models/offer-amount'); + // @ts-expect-error This module lacks type definitions. +} from '../../../../../../../core/server/services/offers/domain/models/offer-amount'; describe('OfferAmount', function () { describe('OfferPercentageAmount', function () { diff --git a/ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.js b/ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.ts similarity index 96% rename from ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.js rename to ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.ts index a1315585997..d3a7088f18d 100644 --- a/ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.js +++ b/ghost/core/test/unit/server/services/offers/domain/models/stripe-coupon.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const StripeCoupon = require('../../../../../../../core/server/services/offers/domain/models/stripe-coupon'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import StripeCoupon from '../../../../../../../core/server/services/offers/domain/models/stripe-coupon'; describe('StripeCoupon', function () { describe('StripeCoupon.create factory', function () { diff --git a/ghost/core/test/unit/server/services/permissions/parse-context.test.js b/ghost/core/test/unit/server/services/permissions/parse-context.test.ts similarity index 94% rename from ghost/core/test/unit/server/services/permissions/parse-context.test.js rename to ghost/core/test/unit/server/services/permissions/parse-context.test.ts index 31cb6c0a1d8..77cce35e55c 100644 --- a/ghost/core/test/unit/server/services/permissions/parse-context.test.js +++ b/ghost/core/test/unit/server/services/permissions/parse-context.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const parseContext = require('../../../../../core/server/services/permissions/parse-context'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import parseContext from '../../../../../core/server/services/permissions/parse-context'; describe('Permissions', function () { describe('parseContext', function () { diff --git a/ghost/core/test/unit/server/services/permissions/providers.test.js b/ghost/core/test/unit/server/services/permissions/providers.test.ts similarity index 95% rename from ghost/core/test/unit/server/services/permissions/providers.test.js rename to ghost/core/test/unit/server/services/permissions/providers.test.ts index 4931e7c93ed..a469680453a 100644 --- a/ghost/core/test/unit/server/services/permissions/providers.test.js +++ b/ghost/core/test/unit/server/services/permissions/providers.test.ts @@ -1,8 +1,11 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const testUtils = require('../../../../utils'); -const models = require('../../../../../core/server/models'); -const providers = require('../../../../../core/server/services/permissions/providers'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import testUtils from '../../../../utils'; +// @ts-expect-error This module lacks type definitions. +import models from '../../../../../core/server/models'; +// @ts-expect-error This module lacks type definitions. +import providers from '../../../../../core/server/services/permissions/providers'; describe('Permission Providers', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/services/settings/private-site-access-code.test.js b/ghost/core/test/unit/server/services/settings/private-site-access-code.test.ts similarity index 73% rename from ghost/core/test/unit/server/services/settings/private-site-access-code.test.js rename to ghost/core/test/unit/server/services/settings/private-site-access-code.test.ts index 1766f32c5ce..a86baa25447 100644 --- a/ghost/core/test/unit/server/services/settings/private-site-access-code.test.js +++ b/ghost/core/test/unit/server/services/settings/private-site-access-code.test.ts @@ -1,8 +1,8 @@ -const assert = require('node:assert/strict'); -const { - ACCESS_CODE_WORDS, - generatePrivateSiteAccessCode, -} = require('../../../../../core/server/services/settings/private-site-access-code'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import * as accessCode from '../../../../../core/server/services/settings/private-site-access-code'; + +const { ACCESS_CODE_WORDS, generatePrivateSiteAccessCode } = accessCode; describe('private-site-access-code', function () { it('returns a curated word with a three-digit suffix', function () { @@ -21,11 +21,11 @@ describe('private-site-access-code', function () { it('uses a 48-word lowercase list with no duplicate entries', function () { assert.equal(ACCESS_CODE_WORDS.length, 48); assert.equal(new Set(ACCESS_CODE_WORDS).size, ACCESS_CODE_WORDS.length); - assert.ok(ACCESS_CODE_WORDS.every((word) => /^[a-z]+$/.test(word))); + assert.ok(ACCESS_CODE_WORDS.every((word: string) => /^[a-z]+$/.test(word))); }); it('produces varied codes across consecutive calls', function () { - const codes = new Set(); + const codes = new Set(); for (let i = 0; i < 50; i++) { codes.add(generatePrivateSiteAccessCode()); } diff --git a/ghost/core/test/unit/server/services/themes/list.test.js b/ghost/core/test/unit/server/services/themes/list.test.ts similarity index 89% rename from ghost/core/test/unit/server/services/themes/list.test.js rename to ghost/core/test/unit/server/services/themes/list.test.ts index 768ab789b78..8c87caf639c 100644 --- a/ghost/core/test/unit/server/services/themes/list.test.js +++ b/ghost/core/test/unit/server/services/themes/list.test.ts @@ -1,8 +1,9 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../utils/assertions'); -const sinon = require('sinon'); -const _ = require('lodash'); -const themeList = require('../../../../../core/server/services/themes/list'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../utils/assertions'; +import sinon from 'sinon'; +import _ from 'lodash'; +// @ts-expect-error This module lacks type definitions. +import themeList from '../../../../../core/server/services/themes/list'; describe('Themes', function () { afterEach(function () { diff --git a/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js b/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js index 85483924221..9758eba3fac 100644 --- a/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js +++ b/ghost/core/test/unit/server/services/tinybird/tinybird-service.test.js @@ -66,6 +66,9 @@ describe('TinybirdService', function () { const result = tinybirdService._generateToken(); const decoded = jwt.verify(result.token, tinybirdConfig.adminToken); assert.ok(decoded); + assert.ok( + decoded.scopes.some((scope) => scope.resource === 'api_automation_performance_stats'), + ); decoded.scopes.forEach((scope) => { assert.ok(scope.type === 'PIPES:READ'); assert.ok(scope.resource); diff --git a/ghost/core/test/unit/server/web/admin/middleware.test.js b/ghost/core/test/unit/server/web/admin/middleware.test.ts similarity index 86% rename from ghost/core/test/unit/server/web/admin/middleware.test.js rename to ghost/core/test/unit/server/web/admin/middleware.test.ts index b66ef262663..b39a6df875f 100644 --- a/ghost/core/test/unit/server/web/admin/middleware.test.js +++ b/ghost/core/test/unit/server/web/admin/middleware.test.ts @@ -1,9 +1,10 @@ -const assert = require('node:assert/strict'); -const express = require('express'); -const request = require('supertest'); +import assert from 'node:assert/strict'; +import express from 'express'; +import request from 'supertest'; // Thing we are testing -const redirectAdminUrls = require('../../../../../core/server/web/admin/middleware/redirect-admin-urls'); +// @ts-expect-error This module lacks type definitions. +import redirectAdminUrls from '../../../../../core/server/web/admin/middleware/redirect-admin-urls'; describe('Admin App', function () { describe('middleware', function () { diff --git a/ghost/core/test/unit/server/web/api/canary/content/middleware.test.js b/ghost/core/test/unit/server/web/api/canary/content/middleware.test.ts similarity index 67% rename from ghost/core/test/unit/server/web/api/canary/content/middleware.test.js rename to ghost/core/test/unit/server/web/api/canary/content/middleware.test.ts index aec2d0929a9..e6efa651443 100644 --- a/ghost/core/test/unit/server/web/api/canary/content/middleware.test.js +++ b/ghost/core/test/unit/server/web/api/canary/content/middleware.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const { assertExists } = require('../../../../../../utils/assertions'); -const middleware = require('../../../../../../../core/server/web/api/endpoints/content/middleware'); +import assert from 'node:assert/strict'; +import { assertExists } from '../../../../../../utils/assertions'; +// @ts-expect-error This module lacks type definitions. +import middleware from '../../../../../../../core/server/web/api/endpoints/content/middleware'; describe('Content API middleware', function () { it('exports an authenticatePublic middleware', function () { diff --git a/ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.js b/ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.ts similarity index 92% rename from ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.js rename to ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.ts index 27ae48c35f9..58026e2c5dd 100644 --- a/ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.js +++ b/ghost/core/test/unit/server/web/api/middleware/api-version-compatibility/legacy-api-path-match.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const legacyApiPathMatch = require('../../../../../../../core/server/web/api/middleware/api-version-compatibility/legacy-api-path-match'); +// @ts-expect-error This module lacks type definitions. +import legacyApiPathMatch from '../../../../../../../core/server/web/api/middleware/api-version-compatibility/legacy-api-path-match'; describe('Legacy Path Match', function () { it('returns null, admin for all supported permutations', function () { diff --git a/ghost/core/test/unit/server/web/gift-preview/image.test.js b/ghost/core/test/unit/server/web/gift-preview/image.test.ts similarity index 91% rename from ghost/core/test/unit/server/web/gift-preview/image.test.js rename to ghost/core/test/unit/server/web/gift-preview/image.test.ts index e5732f2488e..9fe65952fc7 100644 --- a/ghost/core/test/unit/server/web/gift-preview/image.test.js +++ b/ghost/core/test/unit/server/web/gift-preview/image.test.ts @@ -1,8 +1,9 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const sinon = require('sinon'); -const imageModule = require('../../../../../core/server/web/gift-preview/image'); +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import imageModule from '../../../../../core/server/web/gift-preview/image'; const giftPreviewPath = path.dirname( require.resolve('../../../../../core/server/web/gift-preview/image'), diff --git a/ghost/core/test/unit/server/web/shared/middleware/brute.test.js b/ghost/core/test/unit/server/web/shared/middleware/brute.test.ts similarity index 91% rename from ghost/core/test/unit/server/web/shared/middleware/brute.test.js rename to ghost/core/test/unit/server/web/shared/middleware/brute.test.ts index 9f6af51f27f..c1f58a10481 100644 --- a/ghost/core/test/unit/server/web/shared/middleware/brute.test.js +++ b/ghost/core/test/unit/server/web/shared/middleware/brute.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const brute = require('../../../../../../core/server/web/shared/middleware/brute'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error This module lacks type definitions. +import brute from '../../../../../../core/server/web/shared/middleware/brute'; describe('brute middleware', function () { afterEach(function () { diff --git a/ghost/core/test/unit/shared/config/adapter-config.test.js b/ghost/core/test/unit/shared/config/adapter-config.test.ts similarity index 91% rename from ghost/core/test/unit/shared/config/adapter-config.test.js rename to ghost/core/test/unit/shared/config/adapter-config.test.ts index 6ead3bea16e..2943189f2e7 100644 --- a/ghost/core/test/unit/shared/config/adapter-config.test.js +++ b/ghost/core/test/unit/shared/config/adapter-config.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); -const path = require('path'); -const configUtils = require('../../../utils/config-utils'); +import assert from 'node:assert/strict'; +import path from 'path'; +// @ts-expect-error This module lacks type definitions. +import configUtils from '../../../utils/config-utils'; /** * This is a somewhat legacy set of tests that check we get the right values for storage diff --git a/ghost/core/test/unit/shared/config/helpers.test.js b/ghost/core/test/unit/shared/config/helpers.test.ts similarity index 95% rename from ghost/core/test/unit/shared/config/helpers.test.js rename to ghost/core/test/unit/shared/config/helpers.test.ts index 5b9571a7469..bf4f3b8ac85 100644 --- a/ghost/core/test/unit/shared/config/helpers.test.js +++ b/ghost/core/test/unit/shared/config/helpers.test.ts @@ -1,5 +1,6 @@ -const assert = require('node:assert/strict'); -const configUtils = require('../../../utils/config-utils'); +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import configUtils from '../../../utils/config-utils'; describe('vhost utils', function () { beforeEach(function () { diff --git a/ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.js b/ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.ts similarity index 94% rename from ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.js rename to ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.ts index be45392860d..f276d10694b 100644 --- a/ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.js +++ b/ghost/core/test/unit/shared/custom-theme-settings-cache/cache.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const Cache = require('../../../../core/shared/custom-theme-settings-cache/custom-theme-settings-cache'); +// @ts-expect-error This module lacks type definitions. +import Cache from '../../../../core/shared/custom-theme-settings-cache/custom-theme-settings-cache'; describe('Cache', function () { describe('populate()', function () { diff --git a/ghost/core/test/unit/shared/labs-flag-overrides.test.js b/ghost/core/test/unit/shared/labs-flag-overrides.test.ts similarity index 94% rename from ghost/core/test/unit/shared/labs-flag-overrides.test.js rename to ghost/core/test/unit/shared/labs-flag-overrides.test.ts index f02ac226f9c..889f4b55b95 100644 --- a/ghost/core/test/unit/shared/labs-flag-overrides.test.js +++ b/ghost/core/test/unit/shared/labs-flag-overrides.test.ts @@ -1,6 +1,6 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const flagOverrides = require('../../../core/shared/labs-flag-overrides'); +import * as flagOverrides from '../../../core/shared/labs-flag-overrides'; describe('Labs flag overrides store', function () { afterEach(function () { diff --git a/ghost/core/test/utils/automations-fixtures.ts b/ghost/core/test/utils/automations-fixtures.ts index 63dfea85058..49aa5263678 100644 --- a/ghost/core/test/utils/automations-fixtures.ts +++ b/ghost/core/test/utils/automations-fixtures.ts @@ -56,6 +56,7 @@ export async function setupAutomationsFixture(): Promise { created_at: timestamp(0), updated_at: timestamp(0), slug: MEMBER_WELCOME_EMAIL_SLUGS.free, + trigger_tier_scope: 'free', name: 'Free member welcome flow', description: 'Welcome new free members after they sign up.', status: 'active', @@ -65,6 +66,7 @@ export async function setupAutomationsFixture(): Promise { created_at: timestamp(1), updated_at: timestamp(1), slug: MEMBER_WELCOME_EMAIL_SLUGS.paid, + trigger_tier_scope: 'all_paid', name: 'Paid member welcome flow', description: 'Welcome new paid members after they start their subscription.', status: 'active', diff --git a/ghost/core/test/utils/fixtures/schema/v4-fixtures.json b/ghost/core/test/utils/fixtures/schema/v4-fixtures.json new file mode 100644 index 00000000000..9a3d492c2ae --- /dev/null +++ b/ghost/core/test/utils/fixtures/schema/v4-fixtures.json @@ -0,0 +1,778 @@ +{ + "models": [ + { + "name": "Product", + "entries": [ + { + "name": "Free", + "slug": "free", + "type": "free", + "active": true, + "visibility": "public" + }, + { + "name": "Default Product", + "slug": "default-product", + "type": "paid", + "active": true, + "visibility": "public" + } + ] + }, + { + "name": "Newsletter", + "entries": [ + { + "name": "Default Newsletter", + "slug": "default-newsletter", + "show_header_name": false + } + ] + }, + { + "name": "Tag", + "entries": [ + { + "name": "News", + "slug": "news", + "description": null, + "parent_id": null, + "meta_title": null, + "meta_description": null + } + ] + }, + { + "name": "Role", + "entries": [ + { + "name": "Administrator", + "description": "Administrators" + }, + { + "name": "Editor", + "description": "Editors" + }, + { + "name": "Author", + "description": "Authors" + }, + { + "name": "Contributor", + "description": "Contributors" + }, + { + "name": "Owner", + "description": "Blog Owner" + }, + { + "name": "Admin Integration", + "description": "External Apps" + }, + { + "name": "DB Backup Integration", + "description": "Internal DB Backup Client" + }, + { + "name": "Scheduler Integration", + "description": "Internal Scheduler Client" + } + ] + }, + { + "name": "Permission", + "entries": [ + { + "name": "Export database", + "action_type": "exportContent", + "object_type": "db" + }, + { + "name": "Import database", + "action_type": "importContent", + "object_type": "db" + }, + { + "name": "Delete all content", + "action_type": "deleteAllContent", + "object_type": "db" + }, + { + "name": "Send mail", + "action_type": "send", + "object_type": "mail" + }, + { + "name": "Browse notifications", + "action_type": "browse", + "object_type": "notification" + }, + { + "name": "Add notifications", + "action_type": "add", + "object_type": "notification" + }, + { + "name": "Delete notifications", + "action_type": "destroy", + "object_type": "notification" + }, + { + "name": "Browse posts", + "action_type": "browse", + "object_type": "post" + }, + { + "name": "Read posts", + "action_type": "read", + "object_type": "post" + }, + { + "name": "Edit posts", + "action_type": "edit", + "object_type": "post" + }, + { + "name": "Add posts", + "action_type": "add", + "object_type": "post" + }, + { + "name": "Delete posts", + "action_type": "destroy", + "object_type": "post" + }, + { + "name": "Browse settings", + "action_type": "browse", + "object_type": "setting" + }, + { + "name": "Read settings", + "action_type": "read", + "object_type": "setting" + }, + { + "name": "Edit settings", + "action_type": "edit", + "object_type": "setting" + }, + { + "name": "Generate slugs", + "action_type": "generate", + "object_type": "slug" + }, + { + "name": "Browse tags", + "action_type": "browse", + "object_type": "tag" + }, + { + "name": "Read tags", + "action_type": "read", + "object_type": "tag" + }, + { + "name": "Edit tags", + "action_type": "edit", + "object_type": "tag" + }, + { + "name": "Add tags", + "action_type": "add", + "object_type": "tag" + }, + { + "name": "Delete tags", + "action_type": "destroy", + "object_type": "tag" + }, + { + "name": "Browse themes", + "action_type": "browse", + "object_type": "theme" + }, + { + "name": "Edit themes", + "action_type": "edit", + "object_type": "theme" + }, + { + "name": "Activate themes", + "action_type": "activate", + "object_type": "theme" + }, + { + "name": "Upload themes", + "action_type": "add", + "object_type": "theme" + }, + { + "name": "Download themes", + "action_type": "read", + "object_type": "theme" + }, + { + "name": "Delete themes", + "action_type": "destroy", + "object_type": "theme" + }, + { + "name": "Browse users", + "action_type": "browse", + "object_type": "user" + }, + { + "name": "Read users", + "action_type": "read", + "object_type": "user" + }, + { + "name": "Edit users", + "action_type": "edit", + "object_type": "user" + }, + { + "name": "Add users", + "action_type": "add", + "object_type": "user" + }, + { + "name": "Delete users", + "action_type": "destroy", + "object_type": "user" + }, + { + "name": "Assign a role", + "action_type": "assign", + "object_type": "role" + }, + { + "name": "Browse roles", + "action_type": "browse", + "object_type": "role" + }, + { + "name": "Browse invites", + "action_type": "browse", + "object_type": "invite" + }, + { + "name": "Read invites", + "action_type": "read", + "object_type": "invite" + }, + { + "name": "Edit invites", + "action_type": "edit", + "object_type": "invite" + }, + { + "name": "Add invites", + "action_type": "add", + "object_type": "invite" + }, + { + "name": "Delete invites", + "action_type": "destroy", + "object_type": "invite" + }, + { + "name": "Download redirects", + "action_type": "download", + "object_type": "redirect" + }, + { + "name": "Upload redirects", + "action_type": "upload", + "object_type": "redirect" + }, + { + "name": "Add webhooks", + "action_type": "add", + "object_type": "webhook" + }, + { + "name": "Edit webhooks", + "action_type": "edit", + "object_type": "webhook" + }, + { + "name": "Delete webhooks", + "action_type": "destroy", + "object_type": "webhook" + }, + { + "name": "Browse integrations", + "action_type": "browse", + "object_type": "integration" + }, + { + "name": "Read integrations", + "action_type": "read", + "object_type": "integration" + }, + { + "name": "Edit integrations", + "action_type": "edit", + "object_type": "integration" + }, + { + "name": "Add integrations", + "action_type": "add", + "object_type": "integration" + }, + { + "name": "Delete integrations", + "action_type": "destroy", + "object_type": "integration" + }, + { + "name": "Browse API keys", + "action_type": "browse", + "object_type": "api_key" + }, + { + "name": "Read API keys", + "action_type": "read", + "object_type": "api_key" + }, + { + "name": "Edit API keys", + "action_type": "edit", + "object_type": "api_key" + }, + { + "name": "Add API keys", + "action_type": "add", + "object_type": "api_key" + }, + { + "name": "Delete API keys", + "action_type": "destroy", + "object_type": "api_key" + }, + { + "name": "Browse Actions", + "action_type": "browse", + "object_type": "action" + }, + { + "name": "Browse Members", + "action_type": "browse", + "object_type": "member" + }, + { + "name": "Read Members", + "action_type": "read", + "object_type": "member" + }, + { + "name": "Edit Members", + "action_type": "edit", + "object_type": "member" + }, + { + "name": "Add Members", + "action_type": "add", + "object_type": "member" + }, + { + "name": "Delete Members", + "action_type": "destroy", + "object_type": "member" + }, + { + "name": "Browse Products", + "action_type": "browse", + "object_type": "product" + }, + { + "name": "Read Products", + "action_type": "read", + "object_type": "product" + }, + { + "name": "Edit Products", + "action_type": "edit", + "object_type": "product" + }, + { + "name": "Add Products", + "action_type": "add", + "object_type": "product" + }, + { + "name": "Delete Products", + "action_type": "destroy", + "object_type": "product" + }, + { + "name": "Publish posts", + "action_type": "publish", + "object_type": "post" + }, + { + "name": "Backup database", + "action_type": "backupContent", + "object_type": "db" + }, + { + "name": "Email preview", + "action_type": "read", + "object_type": "email_preview" + }, + { + "name": "Send test email", + "action_type": "sendTestEmail", + "object_type": "email_preview" + }, + { + "name": "Browse emails", + "action_type": "browse", + "object_type": "email" + }, + { + "name": "Read emails", + "action_type": "read", + "object_type": "email" + }, + { + "name": "Retry emails", + "action_type": "retry", + "object_type": "email" + }, + { + "name": "Browse labels", + "action_type": "browse", + "object_type": "label" + }, + { + "name": "Read labels", + "action_type": "read", + "object_type": "label" + }, + { + "name": "Edit labels", + "action_type": "edit", + "object_type": "label" + }, + { + "name": "Add labels", + "action_type": "add", + "object_type": "label" + }, + { + "name": "Delete labels", + "action_type": "destroy", + "object_type": "label" + }, + { + "name": "Read member signin urls", + "action_type": "read", + "object_type": "member_signin_url" + }, + { + "name": "Read identities", + "action_type": "read", + "object_type": "identity" + }, + { + "name": "Auth Stripe Connect for Members", + "action_type": "auth", + "object_type": "members_stripe_connect" + }, + { + "name": "Browse snippets", + "action_type": "browse", + "object_type": "snippet" + }, + { + "name": "Read snippets", + "action_type": "read", + "object_type": "snippet" + }, + { + "name": "Edit snippets", + "action_type": "edit", + "object_type": "snippet" + }, + { + "name": "Add snippets", + "action_type": "add", + "object_type": "snippet" + }, + { + "name": "Delete snippets", + "action_type": "destroy", + "object_type": "snippet" + }, + { + "name": "Browse offers", + "action_type": "browse", + "object_type": "offer" + }, + { + "name": "Read offers", + "action_type": "read", + "object_type": "offer" + }, + { + "name": "Edit offers", + "action_type": "edit", + "object_type": "offer" + }, + { + "name": "Add offers", + "action_type": "add", + "object_type": "offer" + }, + { + "name": "Reset all passwords", + "action_type": "resetAllPasswords", + "object_type": "authentication" + }, + { + "name": "Browse custom theme settings", + "action_type": "browse", + "object_type": "custom_theme_setting" + }, + { + "name": "Edit custom theme settings", + "action_type": "edit", + "object_type": "custom_theme_setting" + }, + { + "name": "Browse newsletters", + "action_type": "browse", + "object_type": "newsletter" + }, + { + "name": "Read newsletters", + "action_type": "read", + "object_type": "newsletter" + }, + { + "name": "Add newsletters", + "action_type": "add", + "object_type": "newsletter" + }, + { + "name": "Edit newsletters", + "action_type": "edit", + "object_type": "newsletter" + } + ] + }, + { + "name": "User", + "entries": [ + { + "id": 1, + "name": "Ghost", + "email": "ghost@example.com", + "status": "inactive", + "roles": [] + } + ] + }, + { + "name": "Post", + "entries": [ + { + "title": "Coming soon", + "slug": "coming-soon", + "feature_image": "https://static.ghost.org/v4.0.0/images/feature-image.jpg", + "mobiledoc": "{\"version\":\"0.3.1\",\"atoms\":[],\"cards\":[],\"markups\":[[\"a\",[\"href\",\"#/portal/\",\"rel\",\"noopener noreferrer\"]]],\"sections\":[[1,\"p\",[[0,[],0,\"This is a brand new site that's just getting started. Things will be up and running here shortly, but you can \"],[0,[0],1,\"subscribe\"],[0,[],0,\" in the meantime if you'd like to stay up to date and receive emails when new content is published!\"]]]],\"ghostVersion\":\"4.0\"}", + "featured": false, + "type": "post", + "status": "published", + "visibility": "public", + "created_by": "1", + "published_by": "1", + "author_id": "1" + }, + { + "title": "About this site", + "slug": "about", + "mobiledoc": "{\"version\":\"0.3.1\",\"atoms\":[],\"cards\":[[\"hr\",{}]],\"markups\":[[\"a\",[\"href\",\"https://ghost.org\"]]],\"sections\":[[1,\"p\",[[0,[],0,\"This is an independent publication. If you subscribe today, you'll get full access to the website as well as email newsletters about new content when it's available. Your subscription makes this site possible. Thank you!\"]]],[1,\"h3\",[[0,[],0,\"Access all areas\"]]],[1,\"p\",[[0,[],0,\"By signing up, you'll get access to the full archive of everything that's been published before and everything that's still to come. Your very own private library.\"]]],[1,\"h3\",[[0,[],0,\"Fresh content, delivered\"]]],[1,\"p\",[[0,[],0,\"Stay up to date with new content sent straight to your inbox! No more worrying about whether you missed something because of a pesky algorithm or news feed.\"]]],[1,\"h3\",[[0,[],0,\"Meet people like you\"]]],[1,\"p\",[[0,[],0,\"Join a community of other subscribers who share the same interests.\"]]],[10,0],[1,\"h3\",[[0,[],0,\"Start your own thing\"]]],[1,\"p\",[[0,[],0,\"Enjoying the experience? Get started for free and set up your very own subscription business using \"],[0,[0],1,\"Ghost\"],[0,[],0,\", the same platform that powers this website.\"]]]],\"ghostVersion\":\"4.0\"}", + "featured": false, + "type": "page", + "status": "published", + "visibility": "public", + "created_by": "1", + "published_by": "1", + "author_id": "1" + } + ] + }, + { + "name": "Integration", + "entries": [ + { + "slug": "zapier", + "name": "Zapier", + "description": "Built-in Zapier integration", + "type": "builtin", + "api_keys": [{ "type": "admin" }] + }, + { + "slug": "ghost-backup", + "name": "Ghost Backup", + "description": "Internal DB Backup integration", + "type": "internal", + "api_keys": [{ "type": "admin", "role": "DB Backup Integration" }] + }, + { + "slug": "ghost-scheduler", + "name": "Ghost Scheduler", + "description": "Internal Scheduler integration", + "type": "internal", + "api_keys": [{ "type": "admin", "role": "Scheduler Integration" }] + } + ] + } + ], + "relations": [ + { + "from": { + "model": "Role", + "match": "name", + "relation": "permissions" + }, + "to": { + "model": "Permission", + "match": ["object_type", "action_type"] + }, + "entries": { + "Administrator": { + "db": "all", + "mail": "all", + "notification": "all", + "post": "all", + "setting": "all", + "slug": "all", + "tag": "all", + "theme": "all", + "user": "all", + "role": "all", + "invite": "all", + "redirect": "all", + "webhook": "all", + "integration": "all", + "api_key": "all", + "action": "all", + "member": "all", + "product": "all", + "label": "all", + "email_preview": "all", + "email": "all", + "member_signin_url": "read", + "snippet": "all", + "custom_theme_setting": "all", + "offer": "all", + "authentication": "resetAllPasswords", + "members_stripe_connect": "auth", + "newsletter": "all" + }, + "DB Backup Integration": { + "db": "all" + }, + "Scheduler Integration": { + "post": "publish" + }, + "Admin Integration": { + "mail": "all", + "notification": "all", + "post": "all", + "setting": "all", + "slug": "all", + "tag": "all", + "theme": "all", + "user": "all", + "role": "all", + "invite": "all", + "redirect": "all", + "webhook": "all", + "action": "all", + "member": "all", + "label": "all", + "email_preview": "all", + "email": "all", + "snippet": "all", + "product": ["browse", "read", "add", "edit"], + "offer": ["browse", "read", "add", "edit"] + }, + "Editor": { + "notification": "all", + "post": "all", + "setting": ["browse", "read"], + "slug": "all", + "tag": "all", + "user": "all", + "role": "all", + "invite": "all", + "theme": ["browse"], + "email_preview": "all", + "email": "all", + "snippet": "all", + "label": ["browse", "read"], + "product": ["browse", "read"] + }, + "Author": { + "post": ["browse", "read", "add"], + "setting": ["browse", "read"], + "slug": "all", + "tag": ["browse", "read", "add"], + "user": ["browse", "read"], + "role": ["browse"], + "theme": ["browse"], + "email_preview": "read", + "email": "read", + "snippet": ["browse", "read"], + "product": ["browse", "read"] + }, + "Contributor": { + "post": ["browse", "read", "add"], + "setting": ["browse", "read"], + "slug": "all", + "tag": ["browse", "read"], + "user": ["browse", "read"], + "role": ["browse"], + "theme": ["browse"], + "email_preview": "read", + "email": "read", + "snippet": ["browse", "read"] + } + } + }, + { + "from": { + "model": "Post", + "match": "slug", + "relation": "tags" + }, + "to": { + "model": "Tag", + "match": "name" + }, + "entries": { + "coming-soon": ["News"] + } + }, + { + "from": { + "model": "User", + "match": "id", + "relation": "roles" + }, + "to": { + "model": "Role", + "match": "name" + }, + "entries": { + "1": ["Owner"] + } + } + ] +} diff --git a/ghost/core/test/utils/fixtures/schema/v4.ts b/ghost/core/test/utils/fixtures/schema/v4.ts new file mode 100644 index 00000000000..3b5e19ec116 --- /dev/null +++ b/ghost/core/test/utils/fixtures/schema/v4.ts @@ -0,0 +1,1121 @@ +/** + * schema.js as of Ghost v4.48.9, the last v4 release, converted to TypeScript. + * + * Versioned migrations start from a database in this state, so tests use it to + * build a database from scratch by running every migration. + * + * Should probably not be edited. + * + * @see https://github.com/TryGhost/Ghost/blob/v4.48.9/core/server/data/schema/schema.js + */ +export const v4Schema = { + newsletters: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + uuid: { + type: 'string', + maxlength: 36, + nullable: false, + unique: true, + validations: { isUUID: true }, + }, + name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + description: { type: 'string', maxlength: 2000, nullable: true }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + sender_name: { type: 'string', maxlength: 191, nullable: true }, + sender_email: { type: 'string', maxlength: 191, nullable: true }, + sender_reply_to: { + type: 'string', + maxlength: 191, + nullable: false, + defaultTo: 'newsletter', + validations: { isIn: [['newsletter', 'support']] }, + }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'active', + validations: { isIn: [['active', 'archived']] }, + }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'members', + }, + subscribe_on_signup: { type: 'bool', nullable: false, defaultTo: true }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + header_image: { type: 'string', maxlength: 2000, nullable: true }, + show_header_icon: { type: 'bool', nullable: false, defaultTo: true }, + show_header_title: { type: 'bool', nullable: false, defaultTo: true }, + title_font_category: { + type: 'string', + maxlength: 191, + nullable: false, + defaultTo: 'sans_serif', + validations: { isIn: [['serif', 'sans_serif']] }, + }, + title_alignment: { + type: 'string', + maxlength: 191, + nullable: false, + defaultTo: 'center', + validations: { isIn: [['center', 'left']] }, + }, + show_feature_image: { type: 'bool', nullable: false, defaultTo: true }, + body_font_category: { + type: 'string', + maxlength: 191, + nullable: false, + defaultTo: 'sans_serif', + validations: { isIn: [['serif', 'sans_serif']] }, + }, + footer_content: { type: 'text', maxlength: 1000000000, nullable: true }, + show_badge: { type: 'bool', nullable: false, defaultTo: true }, + show_header_name: { type: 'bool', nullable: false, defaultTo: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + posts: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + uuid: { type: 'string', maxlength: 36, nullable: false, validations: { isUUID: true } }, + title: { + type: 'string', + maxlength: 2000, + nullable: false, + validations: { isLength: { max: 255 } }, + }, + slug: { type: 'string', maxlength: 191, nullable: false }, + mobiledoc: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + html: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + comment_id: { type: 'string', maxlength: 50, nullable: true }, + plaintext: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + feature_image: { type: 'string', maxlength: 2000, nullable: true }, + featured: { type: 'bool', nullable: false, defaultTo: false }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'post', + validations: { isIn: [['post', 'page']] }, + }, + status: { type: 'string', maxlength: 50, nullable: false, defaultTo: 'draft' }, + // NOTE: unused at the moment and reserved for future features + locale: { type: 'string', maxlength: 6, nullable: true }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'public', + }, + email_recipient_filter: { + type: 'text', + maxlength: 1000000000, + nullable: false, + defaultTo: 'none', + }, + /** + * @deprecated: single authors was superceded by multiple authors in Ghost 1.22.0 + * If we keep it, then only, because you can easier query post.author_id than posts_authors[*].sort_order. + */ + author_id: { type: 'string', maxlength: 24, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + /** + * @deprecated: https://github.com/TryGhost/Ghost/issues/10286 + * + * This is valid for all x_by fields. + */ + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + published_at: { type: 'dateTime', nullable: true }, + published_by: { type: 'string', maxlength: 24, nullable: true }, + custom_excerpt: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 300 } }, + }, + codeinjection_head: { type: 'text', maxlength: 65535, nullable: true }, + codeinjection_foot: { type: 'text', maxlength: 65535, nullable: true }, + custom_template: { type: 'string', maxlength: 100, nullable: true }, + canonical_url: { type: 'text', maxlength: 2000, nullable: true }, + newsletter_id: { type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id' }, + '@@UNIQUE_CONSTRAINTS@@': [['slug', 'type']], + }, + posts_meta: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'posts.id', + unique: true, + }, + og_image: { type: 'string', maxlength: 2000, nullable: true }, + og_title: { type: 'string', maxlength: 300, nullable: true }, + og_description: { type: 'string', maxlength: 500, nullable: true }, + twitter_image: { type: 'string', maxlength: 2000, nullable: true }, + twitter_title: { type: 'string', maxlength: 300, nullable: true }, + twitter_description: { type: 'string', maxlength: 500, nullable: true }, + meta_title: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 300 } }, + }, + meta_description: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 500 } }, + }, + email_subject: { type: 'string', maxlength: 300, nullable: true }, + frontmatter: { type: 'text', maxlength: 65535, nullable: true }, + feature_image_alt: { + type: 'string', + maxlength: 191, + nullable: true, + validations: { isLength: { max: 125 } }, + }, + feature_image_caption: { type: 'text', maxlength: 65535, nullable: true }, + email_only: { type: 'bool', nullable: false, defaultTo: false }, + }, + users: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + password: { type: 'string', maxlength: 60, nullable: false }, + email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: true, + validations: { isEmail: true }, + }, + profile_image: { type: 'string', maxlength: 2000, nullable: true }, + cover_image: { type: 'string', maxlength: 2000, nullable: true }, + bio: { + type: 'text', + maxlength: 65535, + nullable: true, + validations: { isLength: { max: 200 } }, + }, + website: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isEmptyOrURL: true }, + }, + location: { + type: 'text', + maxlength: 65535, + nullable: true, + validations: { isLength: { max: 150 } }, + }, + facebook: { type: 'string', maxlength: 2000, nullable: true }, + twitter: { type: 'string', maxlength: 2000, nullable: true }, + accessibility: { type: 'text', maxlength: 65535, nullable: true }, + // TODO: would be good to add validation here to control for all possible status values. + // The ones that come up by reviewing the user model are: + // 'active', 'inactive', 'locked', 'warn-1', 'warn-2', 'warn-3', 'warn-4' + status: { type: 'string', maxlength: 50, nullable: false, defaultTo: 'active' }, + // NOTE: unused at the moment and reserved for future features + locale: { type: 'string', maxlength: 6, nullable: true }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'public', + validations: { isIn: [['public']] }, + }, + meta_title: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 300 } }, + }, + meta_description: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 500 } }, + }, + tour: { type: 'text', maxlength: 65535, nullable: true }, + last_seen: { type: 'dateTime', nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + oauth: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + provider: { type: 'string', maxlength: 50, nullable: false }, + provider_id: { type: 'string', maxlength: 191, nullable: false }, + access_token: { type: 'text', maxlength: 65535, nullable: true }, + refresh_token: { type: 'text', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + user_id: { type: 'string', maxlength: 24, nullable: false, references: 'users.id' }, + }, + posts_authors: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { type: 'string', maxlength: 24, nullable: false, references: 'posts.id' }, + author_id: { type: 'string', maxlength: 24, nullable: false, references: 'users.id' }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + roles: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 50, nullable: false, unique: true }, + description: { type: 'string', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + roles_users: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + role_id: { type: 'string', maxlength: 24, nullable: false }, + user_id: { type: 'string', maxlength: 24, nullable: false }, + }, + permissions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 50, nullable: false, unique: true }, + object_type: { type: 'string', maxlength: 50, nullable: false }, + action_type: { type: 'string', maxlength: 50, nullable: false }, + object_id: { type: 'string', maxlength: 24, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + permissions_users: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + user_id: { type: 'string', maxlength: 24, nullable: false }, + permission_id: { type: 'string', maxlength: 24, nullable: false }, + }, + permissions_roles: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + role_id: { type: 'string', maxlength: 24, nullable: false }, + permission_id: { type: 'string', maxlength: 24, nullable: false }, + }, + settings: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + group: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'core', + validations: { + isIn: [ + [ + 'amp', + 'core', + 'email', + 'labs', + 'members', + 'portal', + 'private', + 'site', + 'slack', + 'theme', + 'unsplash', + 'views', + ], + ], + }, + }, + key: { type: 'string', maxlength: 50, nullable: false, unique: true }, + // NOTE: as JSON objects are no longer stored in `value` we could potentially reduce the maxlength + value: { type: 'text', maxlength: 65535, nullable: true }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { + isIn: [['array', 'string', 'number', 'boolean', 'object']], + }, + }, + flags: { type: 'string', maxlength: 50, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + tags: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { + type: 'string', + maxlength: 191, + nullable: false, + validations: { matches: /^([^,]|$)/ }, + }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + description: { + type: 'text', + maxlength: 65535, + nullable: true, + validations: { isLength: { max: 500 } }, + }, + feature_image: { type: 'string', maxlength: 2000, nullable: true }, + parent_id: { type: 'string', nullable: true }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'public', + validations: { isIn: [['public', 'internal']] }, + }, + og_image: { type: 'string', maxlength: 2000, nullable: true }, + og_title: { type: 'string', maxlength: 300, nullable: true }, + og_description: { type: 'string', maxlength: 500, nullable: true }, + twitter_image: { type: 'string', maxlength: 2000, nullable: true }, + twitter_title: { type: 'string', maxlength: 300, nullable: true }, + twitter_description: { type: 'string', maxlength: 500, nullable: true }, + meta_title: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 300 } }, + }, + meta_description: { + type: 'string', + maxlength: 2000, + nullable: true, + validations: { isLength: { max: 500 } }, + }, + codeinjection_head: { type: 'text', maxlength: 65535, nullable: true }, + codeinjection_foot: { type: 'text', maxlength: 65535, nullable: true }, + canonical_url: { type: 'string', maxlength: 2000, nullable: true }, + accent_color: { type: 'string', maxlength: 50, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + posts_tags: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { type: 'string', maxlength: 24, nullable: false, references: 'posts.id' }, + tag_id: { type: 'string', maxlength: 24, nullable: false, references: 'tags.id' }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + invites: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + role_id: { type: 'string', maxlength: 24, nullable: false }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'pending', + validations: { isIn: [['pending', 'sent']] }, + }, + token: { type: 'string', maxlength: 191, nullable: false, unique: true }, + email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: true, + validations: { isEmail: true }, + }, + expires: { type: 'bigInteger', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + brute: { + key: { type: 'string', maxlength: 191, primary: true }, + firstRequest: { type: 'bigInteger' }, + lastRequest: { type: 'bigInteger' }, + lifetime: { type: 'bigInteger' }, + count: { type: 'integer' }, + }, + sessions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + session_id: { type: 'string', maxlength: 32, nullable: false, unique: true }, + user_id: { type: 'string', maxlength: 24, nullable: false }, + session_data: { type: 'string', maxlength: 2000, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + integrations: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'custom', + validations: { isIn: [['internal', 'builtin', 'custom']] }, + }, + name: { type: 'string', maxlength: 191, nullable: false }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + icon_image: { type: 'string', maxlength: 2000, nullable: true }, + description: { type: 'string', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + webhooks: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + event: { type: 'string', maxlength: 50, nullable: false, validations: { isLowercase: true } }, + target_url: { type: 'string', maxlength: 2000, nullable: false }, + name: { type: 'string', maxlength: 191, nullable: true }, + secret: { type: 'string', maxlength: 191, nullable: true }, + // NOTE: the defaultTo does not make sense to set on DB layer as it leads to unnecessary maintenance every major release + // it might make sense to introduce "isIn" validation checking if it's a valid version e.g: 'v3', 'v4', 'canary' + api_version: { type: 'string', maxlength: 50, nullable: false, defaultTo: 'v2' }, + // NOTE: integration_id column needs "nullable: true" -> "nullable: false" migration (recreate table with nullable: false) + // CASE: Ghost instances initialized pre 4.0 will have this column set to nullable: true in db schema + integration_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'integrations.id', + cascadeDelete: true, + }, + status: { type: 'string', maxlength: 50, nullable: false, defaultTo: 'available' }, + last_triggered_at: { type: 'dateTime', nullable: true }, + last_triggered_status: { type: 'string', maxlength: 50, nullable: true }, + last_triggered_error: { type: 'string', maxlength: 50, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + api_keys: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { isIn: [['content', 'admin']] }, + }, + secret: { + type: 'string', + maxlength: 191, + nullable: false, + unique: true, + validations: { isLength: { min: 26, max: 128 } }, + }, + role_id: { type: 'string', maxlength: 24, nullable: true }, + // integration_id is nullable to allow "internal" API keys that don't show in the UI + integration_id: { type: 'string', maxlength: 24, nullable: true }, + user_id: { type: 'string', maxlength: 24, nullable: true }, + last_seen_at: { type: 'dateTime', nullable: true }, + last_seen_version: { type: 'string', maxlength: 50, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + mobiledoc_revisions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { type: 'string', maxlength: 24, nullable: false, index: true }, + mobiledoc: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + created_at_ts: { type: 'bigInteger', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + }, + members: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + uuid: { + type: 'string', + maxlength: 36, + nullable: true, + unique: true, + validations: { isUUID: true }, + }, + email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: true, + validations: { isEmail: true }, + }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'free', + validations: { + isIn: [['free', 'paid', 'comped']], + }, + }, + name: { type: 'string', maxlength: 191, nullable: true }, + note: { type: 'string', maxlength: 2000, nullable: true }, + geolocation: { type: 'string', maxlength: 2000, nullable: true }, + subscribed: { type: 'bool', nullable: true, defaultTo: true }, + email_count: { type: 'integer', unsigned: true, nullable: false, defaultTo: 0 }, + email_opened_count: { type: 'integer', unsigned: true, nullable: false, defaultTo: 0 }, + email_open_rate: { type: 'integer', unsigned: true, nullable: true, index: true }, + last_seen_at: { type: 'dateTime', nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + products: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + active: { type: 'boolean', nullable: false, defaultTo: true }, + welcome_page_url: { type: 'string', maxlength: 2000, nullable: true }, + visibility: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'none', + validations: { isIn: [['public', 'none']] }, + }, + monthly_price_id: { type: 'string', maxlength: 24, nullable: true }, + yearly_price_id: { type: 'string', maxlength: 24, nullable: true }, + description: { type: 'string', maxlength: 191, nullable: true }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'paid', + validations: { isIn: [['paid', 'free']] }, + }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + offers: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + active: { type: 'boolean', nullable: false, defaultTo: true }, + name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + code: { type: 'string', maxlength: 191, nullable: false, unique: true }, + product_id: { type: 'string', maxlength: 24, nullable: false, references: 'products.id' }, + stripe_coupon_id: { type: 'string', maxlength: 255, nullable: true, unique: true }, + interval: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { isIn: [['month', 'year']] }, + }, + currency: { type: 'string', maxlength: 50, nullable: true }, + discount_type: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { isIn: [['percent', 'amount']] }, + }, + discount_amount: { type: 'integer', nullable: false }, + duration: { type: 'string', maxlength: 50, nullable: false }, + duration_in_months: { type: 'integer', nullable: true }, + portal_title: { type: 'string', maxlength: 191, nullable: true }, + portal_description: { type: 'string', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + benefits: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + products_benefits: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'products.id', + cascadeDelete: true, + }, + benefit_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'benefits.id', + cascadeDelete: true, + }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + members_products: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'products.id', + cascadeDelete: true, + }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + posts_products: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'posts.id', + cascadeDelete: true, + }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'products.id', + cascadeDelete: true, + }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + members_cancel_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + from_plan: { type: 'string', maxlength: 255, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_payment_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + amount: { type: 'integer', nullable: false }, + currency: { type: 'string', maxLength: 3, nullable: false }, + source: { type: 'string', maxlength: 50, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_login_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_email_change_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + to_email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: false, + validations: { isEmail: true }, + }, + from_email: { + type: 'string', + maxlength: 191, + nullable: false, + unique: false, + validations: { isEmail: true }, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_status_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + from_status: { + type: 'string', + maxlength: 50, + nullable: true, + validations: { + isIn: [['free', 'paid', 'comped']], + }, + }, + to_status: { + type: 'string', + maxlength: 50, + nullable: true, + validations: { + isIn: [['free', 'paid', 'comped']], + }, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_product_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'products.id', + cascadeDelete: false, + }, + action: { + type: 'string', + maxlength: 50, + nullable: true, + validations: { + isIn: [['added', 'removed']], + }, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_paid_subscription_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + type: { type: 'string', maxlength: 50, nullable: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + subscription_id: { type: 'string', maxlength: 24, nullable: true }, + from_plan: { type: 'string', maxlength: 255, nullable: true }, + to_plan: { type: 'string', maxlength: 255, nullable: true }, + currency: { type: 'string', maxLength: 3, nullable: false }, + source: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { + isIn: [['stripe']], + }, + }, + mrr_delta: { type: 'integer', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + }, + labels: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + slug: { type: 'string', maxlength: 191, nullable: false, unique: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + members_labels: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + label_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'labels.id', + cascadeDelete: true, + }, + sort_order: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + }, + members_stripe_customers: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + unique: false, + references: 'members.id', + cascadeDelete: true, + }, + customer_id: { type: 'string', maxlength: 255, nullable: false, unique: true }, + name: { type: 'string', maxlength: 191, nullable: true }, + email: { type: 'string', maxlength: 191, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + members_stripe_customers_subscriptions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + customer_id: { + type: 'string', + maxlength: 255, + nullable: false, + unique: false, + references: 'members_stripe_customers.customer_id', + cascadeDelete: true, + }, + subscription_id: { type: 'string', maxlength: 255, nullable: false, unique: true }, + stripe_price_id: { + type: 'string', + maxlength: 255, + nullable: false, + unique: false, + index: true, + defaultTo: '', + }, + status: { type: 'string', maxlength: 50, nullable: false }, + cancel_at_period_end: { type: 'bool', nullable: false, defaultTo: false }, + cancellation_reason: { type: 'string', maxlength: 500, nullable: true }, + current_period_end: { type: 'dateTime', nullable: false }, + start_date: { type: 'dateTime', nullable: false }, + default_payment_card_last4: { type: 'string', maxlength: 4, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + mrr: { type: 'integer', unsigned: true, nullable: false, defaultTo: 0 }, + offer_id: { + type: 'string', + maxlength: 24, + nullable: true, + unique: false, + references: 'offers.id', + }, + /* Below fields are now redundant as we link prie_id to stripe_prices table */ + plan_id: { type: 'string', maxlength: 255, nullable: false, unique: false }, + plan_nickname: { type: 'string', maxlength: 50, nullable: false }, + plan_interval: { type: 'string', maxlength: 50, nullable: false }, + plan_amount: { type: 'integer', nullable: false }, + plan_currency: { type: 'string', maxLength: 3, nullable: false }, + }, + offer_redemptions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + offer_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'offers.id', + cascadeDelete: true, + }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + subscription_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members_stripe_customers_subscriptions.id', + cascadeDelete: true, + }, + created_at: { type: 'dateTime', nullable: false }, + }, + members_subscribe_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + unique: false, + references: 'members.id', + cascadeDelete: true, + }, + subscribed: { type: 'bool', nullable: false, defaultTo: true }, + created_at: { type: 'dateTime', nullable: false }, + source: { type: 'string', maxlength: 50, nullable: true }, + newsletter_id: { + type: 'string', + maxlength: 24, + nullable: true, + references: 'newsletters.id', + cascadeDelete: false, + }, + }, + stripe_products: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + product_id: { + type: 'string', + maxlength: 24, + nullable: false, + unique: false, + references: 'products.id', + }, + stripe_product_id: { type: 'string', maxlength: 255, nullable: false, unique: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + stripe_prices: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + stripe_price_id: { type: 'string', maxlength: 255, nullable: false, unique: true }, + stripe_product_id: { + type: 'string', + maxlength: 255, + nullable: false, + unique: false, + references: 'stripe_products.stripe_product_id', + }, + active: { type: 'bool', nullable: false }, + nickname: { type: 'string', maxlength: 50, nullable: true }, + currency: { type: 'string', maxLength: 3, nullable: false }, + amount: { type: 'integer', nullable: false }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'recurring', + validations: { isIn: [['recurring', 'one_time']] }, + }, + interval: { type: 'string', maxlength: 50, nullable: true }, + description: { type: 'string', maxlength: 191, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, + actions: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + resource_id: { type: 'string', maxlength: 24, nullable: true }, + resource_type: { type: 'string', maxlength: 50, nullable: false }, + actor_id: { type: 'string', maxlength: 24, nullable: false }, + actor_type: { type: 'string', maxlength: 50, nullable: false }, + // @NOTE: The event column contains short buzzwords e.g. subscribed, started, added, deleted, edited etc. + // We already store and require the target resource type. No need to remember e.g. post.edited + event: { type: 'string', maxlength: 50, nullable: false }, + // @NOTE: The context object can be used to store information about an action e.g. diffs, meta + context: { type: 'text', maxlength: 1000000000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + }, + emails: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + post_id: { type: 'string', maxlength: 24, nullable: false, index: true, unique: true }, + uuid: { type: 'string', maxlength: 36, nullable: false, validations: { isUUID: true } }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'pending', + validations: { isIn: [['pending', 'submitting', 'submitted', 'failed']] }, + }, + recipient_filter: { + type: 'text', + maxlength: 1000000000, + nullable: false, + defaultTo: 'status:-free', + }, + error: { type: 'string', maxlength: 2000, nullable: true }, + error_data: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + email_count: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + delivered_count: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + opened_count: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + failed_count: { type: 'integer', nullable: false, unsigned: true, defaultTo: 0 }, + subject: { type: 'string', maxlength: 300, nullable: true }, + from: { type: 'string', maxlength: 2000, nullable: true }, + reply_to: { type: 'string', maxlength: 2000, nullable: true }, + html: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + plaintext: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true }, + track_opens: { type: 'bool', nullable: false, defaultTo: false }, + submitted_at: { type: 'dateTime', nullable: false }, + newsletter_id: { type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id' }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + email_batches: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + email_id: { type: 'string', maxlength: 24, nullable: false, references: 'emails.id' }, + provider_id: { type: 'string', maxlength: 255, nullable: true }, + status: { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'pending', + validations: { isIn: [['pending', 'submitting', 'submitted', 'failed']] }, + }, + member_segment: { type: 'text', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: false }, + }, + email_recipients: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + email_id: { type: 'string', maxlength: 24, nullable: false, references: 'emails.id' }, + member_id: { type: 'string', maxlength: 24, nullable: false, index: true }, + batch_id: { type: 'string', maxlength: 24, nullable: false, references: 'email_batches.id' }, + processed_at: { type: 'dateTime', nullable: true }, + delivered_at: { type: 'dateTime', nullable: true, index: true }, + opened_at: { type: 'dateTime', nullable: true, index: true }, + failed_at: { type: 'dateTime', nullable: true, index: true }, + member_uuid: { type: 'string', maxlength: 36, nullable: false }, + member_email: { type: 'string', maxlength: 191, nullable: false }, + member_name: { type: 'string', maxlength: 191, nullable: true }, + '@@INDEXES@@': [['email_id', 'member_email']], + }, + tokens: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + token: { type: 'string', maxlength: 32, nullable: false, index: true }, + data: { type: 'string', maxlength: 2000, nullable: true }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + }, + snippets: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + mobiledoc: { type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + created_by: { type: 'string', maxlength: 24, nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + updated_by: { type: 'string', maxlength: 24, nullable: true }, + }, + temp_member_analytic_events: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + event_name: { type: 'string', maxlength: 50, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + member_id: { type: 'string', maxlength: 24, nullable: false }, + member_status: { type: 'string', maxlength: 50, nullable: false }, + entry_id: { type: 'string', maxlength: 24, nullable: true }, + source_url: { type: 'string', maxlength: 2000, nullable: true }, + metadata: { type: 'string', maxlength: 191, nullable: true }, + }, + custom_theme_settings: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + theme: { type: 'string', maxlength: 191, nullable: false }, + key: { type: 'string', maxlength: 191, nullable: false }, + type: { + type: 'string', + maxlength: 50, + nullable: false, + validations: { + isIn: [['select', 'boolean', 'color', 'text', 'image']], + }, + }, + value: { type: 'text', maxlength: 65535, nullable: true }, + }, + members_newsletters: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + member_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'members.id', + cascadeDelete: true, + }, + newsletter_id: { + type: 'string', + maxlength: 24, + nullable: false, + references: 'newsletters.id', + cascadeDelete: true, + }, + }, +}; diff --git a/koenig/kg-default-nodes/src/nodes/embed/EmbedNode.ts b/koenig/kg-default-nodes/src/nodes/embed/EmbedNode.ts index 49ee924eb6c..84a4e57ccb3 100644 --- a/koenig/kg-default-nodes/src/nodes/embed/EmbedNode.ts +++ b/koenig/kg-default-nodes/src/nodes/embed/EmbedNode.ts @@ -9,7 +9,9 @@ const embedProperties = { metadata: { get default() { return {} as Record; - } + }, + urlType: 'url', + urlPath: 'metadata.thumbnail_url' }, caption: {default: '', wordCount: true} } satisfies DecoratorNodePropertyMap; diff --git a/koenig/kg-default-nodes/test/nodes/embed.test.ts b/koenig/kg-default-nodes/test/nodes/embed.test.ts index 4cfd6c1628f..ad5d3e9d20f 100644 --- a/koenig/kg-default-nodes/test/nodes/embed.test.ts +++ b/koenig/kg-default-nodes/test/nodes/embed.test.ts @@ -141,7 +141,8 @@ describe('EmbedNode', function () { describe('urlTransformMap', function () { it('contains the expected URL mapping', editorTest(function () { expect(EmbedNode.urlTransformMap).toEqual({ - url: 'url' + url: 'url', + 'metadata.thumbnail_url': 'url' }); })); }); diff --git a/koenig/koenig-lexical/public/embed-renderer/v1.html b/koenig/koenig-lexical/public/embed-renderer/v1.html index c15c0713da1..250a83ae7c0 100644 --- a/koenig/koenig-lexical/public/embed-renderer/v1.html +++ b/koenig/koenig-lexical/public/embed-renderer/v1.html @@ -26,6 +26,8 @@ body.style.justifyContent = 'center'; // don't stretch embeds to the frame, or ones that clip overflow (eg, TikTok) never grow body.style.alignItems = 'flex-start'; + // the frame is sized to fit, and a scrollbar would change the width the height is worked out from + document.documentElement.style.overflow = 'hidden'; function setSize(element, property, value) { if (element.style[property] !== value) { diff --git a/koenig/koenig-lexical/src/components/KoenigComposer.tsx b/koenig/koenig-lexical/src/components/KoenigComposer.tsx index 1174d63d15b..1439f168631 100644 --- a/koenig/koenig-lexical/src/components/KoenigComposer.tsx +++ b/koenig/koenig-lexical/src/components/KoenigComposer.tsx @@ -118,7 +118,8 @@ const KoenigComposer = ({ multiplayerDocId, multiplayerUsername, createWebsocketProvider, - onWordCountChangeRef + onWordCountChangeRef, + onError }}> diff --git a/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx b/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx index 5c6fdaee22b..027e5793041 100644 --- a/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx +++ b/koenig/koenig-lexical/src/components/ui/cards/EmbedCard.tsx @@ -1,4 +1,5 @@ import '@tryghost/kg-simplemde/dist/simplemde.min.css'; +import KoenigComposerContext from '../../../context/KoenigComposerContext'; import PropTypes from 'prop-types'; import React from 'react'; import {CardCaptionEditor} from '../CardCaptionEditor'; @@ -156,10 +157,19 @@ function EmbedIframe({dataTestId, html}) { function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { const iframeRef = React.useRef(null); const [unavailable, setUnavailable] = React.useState(!rendererUrl); + const {onError} = React.useContext(KoenigComposerContext); + const loadedRef = React.useRef(false); + + // reports to the host, so a preview that fails to load can be debugged + const fail = (reason: string) => { + setUnavailable(true); + onError?.(new Error(`Embed renderer unavailable: ${reason}`)); + }; // a layout effect listens before the iframe can run, so a cached renderer's ready message isn't missed React.useLayoutEffect(() => { if (!rendererUrl) { + fail('invalid url'); return; } @@ -175,7 +185,9 @@ function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { if (event.data?.type === EMBED_READY_MESSAGE && !rendered) { if (event.data.version !== EMBED_RENDERER_VERSION) { - setUnavailable(true); + // the effect isn't cleaned up on failure, so stop the timeout reporting again + window.clearTimeout(timeout); + fail(`renderer is v${event.data.version}, editor is v${EMBED_RENDERER_VERSION}`); return; } @@ -198,7 +210,8 @@ function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { // the renderer can be blocked, offline or misconfigured const timeout = window.setTimeout(() => { if (!rendered) { - setUnavailable(true); + // the frame loads for error pages too, so loaded means the wrong document came back + fail(`timed out, frame ${loadedRef.current ? 'loaded' : 'never loaded'}`); } }, EMBED_RENDERER_TIMEOUT); @@ -206,6 +219,8 @@ function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { window.removeEventListener('message', handleMessage); window.clearTimeout(timeout); }; + // `fail` is recreated every render and mustn't restart the handshake + // eslint-disable-next-line react-hooks/exhaustive-deps }, [html, rendererUrl]); if (unavailable) { @@ -221,7 +236,10 @@ function RendererEmbedIframe({dataTestId, html, rendererUrl, url}) { sandbox={EMBED_RENDERER_PERMISSIONS} src={rendererUrl} tabIndex={-1} - title="embed-card-iframe"> + title="embed-card-iframe" + onLoad={() => { + loadedRef.current = true; + }}> ); } diff --git a/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts b/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts index 710ee29f5c6..62729dd2000 100644 --- a/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts +++ b/koenig/koenig-lexical/test/e2e/cards/embed-card.test.ts @@ -3,7 +3,7 @@ import path from 'path'; import {E2E_PORT} from '../../../playwright.config'; import {EMBED_RENDERER_MAX_HEIGHT} from '../../../src/utils/embed-renderer'; import {assertHTML, createSnippet, focusEditor, html, initialize, isMac, pasteText} from '../../utils/e2e'; -import {expect, test} from '@playwright/test'; +import {chromium, expect, test} from '@playwright/test'; import {fileURLToPath} from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -154,6 +154,27 @@ test.describe('Embed card', async () => { await expect(iframe).toHaveCSS('height', `${EMBED_RENDERER_MAX_HEIGHT}px`); }); + test('settles on a height when the renderer shows scrollbars that take up space', async function () { + // Playwright hides scrollbars by default, and a scrollbar narrows the + // renderer, changing the height it works out from its width + const browser = await chromium.launch({ignoreDefaultArgs: ['--hide-scrollbars']}); + + try { + const scrollbarPage = await browser.newPage({baseURL: `http://localhost:${E2E_PORT}`}); + await scrollbarPage.route(`${rendererDirectory}**`, route => route.fulfill({path: rendererFile, contentType: 'text/html'})); + + const embedHtml = ''; + await initialize({page: scrollbarPage, uri: `/#/?embedPreviewUrl=${encodeURIComponent(rendererDirectory)}&content=${embedContent(embedHtml)}`}); + + const iframe = scrollbarPage.getByTestId('embed-iframe'); + const {width} = await iframe.boundingBox(); + + await expect(iframe).toHaveCSS('height', `${Math.ceil(width / (200 / 113))}px`); + } finally { + await browser.close(); + } + }); + test('shows a placeholder when the renderer is on the editor origin', async function () { const sameOriginDirectory = `http://localhost:${E2E_PORT}/embed-renderer/`; diff --git a/packages/adapters/sso-base/README.md b/packages/adapters/sso-base/README.md index 2ca44fb5a8a..6e9fe6357a4 100644 --- a/packages/adapters/sso-base/README.md +++ b/packages/adapters/sso-base/README.md @@ -26,6 +26,9 @@ Extend `SSOBase` and implement every method listed in `requiredFns`: Ghost injects a user repository after constructing the adapter, exposed through the protected `getUserByEmail(email)` and `getOwnerUser()` helpers — use these to resolve Ghost users without reaching into Ghost's model layer directly. +Ghost supplies `id`, `email`, and `status`. Adapters can require +`status === 'active'` before granting a session, including when the owner is still the inactive +placeholder used by first-run setup. Older Ghost versions omit `status`. ```js const {SSOBase} = require('@tryghost/adapter-base-sso'); diff --git a/packages/adapters/sso-base/src/base.ts b/packages/adapters/sso-base/src/base.ts index 888af27aba0..292f3a8a8c4 100644 --- a/packages/adapters/sso-base/src/base.ts +++ b/packages/adapters/sso-base/src/base.ts @@ -4,6 +4,7 @@ import errors from '@tryghost/errors'; export interface User { id: string; email: string; + status?: string; } /** diff --git a/packages/testing/test-data/src/selectors/post-analytics.ts b/packages/testing/test-data/src/selectors/post-analytics.ts index bebbbda9daa..7d5e89d6b05 100644 --- a/packages/testing/test-data/src/selectors/post-analytics.ts +++ b/packages/testing/test-data/src/selectors/post-analytics.ts @@ -17,7 +17,7 @@ export const giftLinkViews = 'gift-link-views'; export const giftLinkCardVisitors = 'gift-link-card-visitors'; export const statsFilterContainer = 'stats-filter-container'; export const statsFilterClearButton = 'stats-filter-clear-button'; -export const emailSendingStatusBanner = 'email-sending-status-banner'; +export const emailSendingStatusLine = 'email-sending-status-line'; /** Per-row testid prefixes; append the source domain / country code. */ export const sourceRowPrefix = 'source-row-'; export const locationRowPrefix = 'location-row-';