Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cd02dbb
Fixed a saved members view opening unfiltered when clicked straight a…
rob-ghost Sep 16, 2026
d59c66d
Moved the member avatar rule out of the member model
rob-ghost Sep 16, 2026
fd4ca59
Added a table recording which metafields a member changed
rob-ghost Sep 16, 2026
97ff2cb
Added member custom field changes to the activity feed API
rob-ghost Sep 16, 2026
4135878
Added custom field changes to the member activity feed in Admin
rob-ghost Sep 16, 2026
417618e
Added custom field changes to the Ember member activity page
rob-ghost Sep 16, 2026
bf56be2
Moved newsletter sending to a serialisable job
allouis Sep 17, 2026
ddaf7d6
🎨 Improved sitemap memory use on sites with many posts (#30838)
acburdine Sep 17, 2026
1764e5e
🎨 Improved site responsiveness while the sitemap rebuilds (#30839)
acburdine Sep 17, 2026
becf78d
Added internal docs for common E2E test error (#30837)
EvanHahn Sep 17, 2026
a9135ee
Changed the post settings sections to a narrow session port (#30755)
9larsons Sep 17, 2026
c84ff92
Changed the React editor's capped browses to share their params and p…
9larsons Sep 17, 2026
e5c7389
Added progressive payment failure warnings to Admin
sagzy Sep 17, 2026
54c310a
Moved the scheduler idempotency key helper next to the other scheduli…
sagzy Sep 9, 2026
8547a26
Added an idempotency key to scheduled post and page jobs
sagzy Sep 9, 2026
dfc51ac
Made the scheduled publish callback safe to receive twice
sagzy Sep 9, 2026
1bae24f
Fixed scheduled newsletter publishes deadlocking on their own email (…
sam-lord Sep 10, 2026
aeaf39f
Fixed Admin 7 pill style inconsistencies (#30849)
peterzimon Sep 17, 2026
00d95c7
Added characterization tests for the email analytics fetch loop
allouis Sep 17, 2026
878f147
Changed email analytics to construct executors with their dependencies
allouis Sep 16, 2026
1dbdf28
Cleaned up unreachable guards in email analytics
allouis Sep 16, 2026
034a24d
Changed the email analytics wrapper to take its job name as a parameter
allouis Sep 17, 2026
9d68544
Added a structured completion event to the email analytics run log
allouis Sep 17, 2026
4fcbf09
Cleaned up duplicated recurring scheduling in the email analytics sch…
allouis Sep 17, 2026
5fdc806
🐛 Fixed comments on gated posts being hidden from readers without acc…
acburdine Sep 17, 2026
7802caa
Fixed request queue stalling when clients disconnect during a render …
acburdine Sep 17, 2026
6f86cb6
Replaced express-queue with an in-house request queue (#30853)
acburdine Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/admin-x-framework/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ export type Config = {
logoDark?: string; // Logo shown in dark mode, falls back to logo
logoAlt?: string; // Alt text for the logo
};
// Payment-failure (dunning) state for the site's hosting subscription.
// Managed hosting providers set this while a payment is outstanding;
// Admin escalates from a warning banner to a locked overlay based on
// the position within the paymentFailedAt -> suspendsAt window.
dunning?: {
active?: boolean; // Only true while the payment is outstanding
paymentFailedAt?: string; // ISO date the payment first failed (window start)
suspendsAt?: string; // ISO date the host will suspend the site (window end)
};
// Search entries for billing paths, defined in host config (hostSettings.billing.search: {})
search?: {
groupName?: string;
Expand Down
32 changes: 32 additions & 0 deletions apps/admin-x-framework/src/api/dunning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { z } from 'zod';

/**
* sessionStorage keys the React admin's dunning UI and the Ember billing
* service handshake through — one side writes, the other consumes. Defined
* here so the cross-app contract lives in one place.
*/

/** Route a "Pay now" CTA was clicked on; the post-payment return lands there. */
export const DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY = 'ghost-dunning-pay-return-route';

/** `paymentFailedAt` of a failure settled by a completed payment this session. */
export const DUNNING_PAYMENT_SETTLED_STORAGE_KEY = 'ghost-dunning-payment-settled-for';

const dateString = z
.string()
.transform((value) => new Date(value))
.pipe(z.date());

const dunningConfigSchema = z
.object({
active: z.literal(true),
paymentFailedAt: dateString,
suspendsAt: dateString,
})
.refine(({ paymentFailedAt, suspendsAt }) => suspendsAt.getTime() > paymentFailedAt.getTime());

/** Shared by React warnings and the legacy alert so invalid config never hides both. */
export function parseDunningConfig(value: unknown) {
const result = dunningConfigSchema.safeParse(value);
return result.success ? result.data : null;
}
18 changes: 13 additions & 5 deletions apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createQuery,
createQueryWithId,
} from '../utils/api/hooks';
import { escapeNqlString } from '@tryghost/nql-string';
import { apiUrl, type RequestOptions } from '../utils/api/fetch-api';
import type { FieldValue } from '@tryghost/metafield-types';
import { useCurrentUser } from './current-user';
Expand Down Expand Up @@ -872,8 +873,15 @@ function memberEventsCursor(events: MemberActivityEvent[]): string | undefined {
return new Date(createdAt).toISOString().slice(0, 19).replace('T', ' ');
}

function buildMemberEventsFilter(memberId: string): string {
return `data.member_id:'${memberId}'`;
// The same exclusion the full activity page sends, so the preview and the page leave out
// the same events.
function buildMemberEventsFilter(memberId: string, excludedEvents: string[]): string {
return [
excludedEvents.length > 0 && `type:-[${excludedEvents.map(escapeNqlString).join(',')}]`,
`data.member_id:'${memberId}'`,
]
.filter(Boolean)
.join('+');
}

const useMemberActivityFeedQuery = createInfiniteQuery<MemberActivityFeedInfiniteResponseType>({
Expand Down Expand Up @@ -914,12 +922,12 @@ const useMemberActivityFeedQuery = createInfiniteQuery<MemberActivityFeedInfinit

export function useMemberActivityFeed(
memberId: string,
options: { enabled?: boolean; limit?: string } = {},
options: { enabled?: boolean; limit?: string; excludedEvents?: string[] } = {},
) {
const { limit = MEMBER_ACTIVITY_LIMIT, enabled } = options;
const { limit = MEMBER_ACTIVITY_LIMIT, enabled, excludedEvents = [] } = options;
return useMemberActivityFeedQuery({
searchParams: {
filter: buildMemberEventsFilter(memberId),
filter: buildMemberEventsFilter(memberId, excludedEvents),
limit,
},
...(enabled !== undefined ? { enabled } : {}),
Expand Down
46 changes: 46 additions & 0 deletions apps/admin-x-framework/test/unit/api/dunning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY,
DUNNING_PAYMENT_SETTLED_STORAGE_KEY,
parseDunningConfig,
} from '../../../src/api/dunning';

const validConfig = {
active: true,
paymentFailedAt: '2026-09-01T00:00:00Z',
suspendsAt: '2026-09-29T00:00:00Z',
};

describe('parseDunningConfig', () => {
it.each([
undefined,
null,
{ active: true },
{ ...validConfig, active: false },
{ ...validConfig, active: 'false' },
{ ...validConfig, paymentFailedAt: 'invalid' },
{ ...validConfig, paymentFailedAt: 1 },
{ ...validConfig, suspendsAt: true },
{ ...validConfig, suspendsAt: validConfig.paymentFailedAt },
{ ...validConfig, suspendsAt: '2026-08-01' },
])('rejects unusable host config: %j', (config) => {
expect(parseDunningConfig(config)).toBeNull();
});

it('pins the storage keys both apps handshake through', () => {
// These literals are the on-the-wire contract between the React admin and
// the Ember billing service; renaming the constants must not change them.
expect(DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY).toBe('ghost-dunning-pay-return-route');
expect(DUNNING_PAYMENT_SETTLED_STORAGE_KEY).toBe('ghost-dunning-payment-settled-for');
});

it('parses valid dates, including host timezone offsets', () => {
expect(
parseDunningConfig({ ...validConfig, paymentFailedAt: '2026-09-01T04:00:00+04:00' }),
).toEqual({
active: true,
paymentFailedAt: new Date(validConfig.paymentFailedAt),
suspendsAt: new Date(validConfig.suspendsAt),
});
});
});
4 changes: 3 additions & 1 deletion apps/admin/src/automations/components/automation-header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AutomationStatusBadge from './automation-status-badge';
import React from 'react';
import { useShade } from '@tryghost/shade/app';
import { Button, type ButtonProps, Skeleton } from '@tryghost/shade/components';
import { Link } from '@tryghost/admin-x-framework';
import { LucideIcon } from '@tryghost/shade/utils';
Expand Down Expand Up @@ -36,13 +37,14 @@ const AutomationHeader: React.FC<AutomationHeaderProps> = ({
onPublish,
onTurnOff,
}) => {
const { isAdmin7 } = useShade();
const name = automation?.name;
const status = automation?.status;

return (
<header className="relative z-10 flex h-14 shrink-0 items-center justify-between bg-surface-elevated px-4 shadow-sm dark:border-b dark:border-gray-950">
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" asChild>
<Button size={isAdmin7 ? 'icon' : undefined} variant="ghost" asChild>
<Link aria-label="Back to automations" to="/automations">
<LucideIcon.ArrowLeft strokeWidth={2} />
</Link>
Expand Down
59 changes: 59 additions & 0 deletions apps/admin/src/dunning/dunning-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Inline } from '@tryghost/shade/primitives';
import { LucideIcon, cn } from '@tryghost/shade/utils';
import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user';
import { isOwnerUser } from '@tryghost/admin-x-framework/api/users';
import { useLocation } from '@tryghost/admin-x-framework';
import { useDunningState } from './use-dunning-state';
import { useDunningLockTakeover } from './use-dunning-lock-takeover';
import { PayNowButton } from './pay-now-button';
import { bannerMessage, bannerTitle } from './dunning-copy';
import { isBillingRoute } from './stand-down-routes';

/**
* Top-of-content warning strip. Carries the dunning message whenever the
* full-page takeover isn't doing so: through the warning phase, and in the
* locked phase once the takeover was dismissed or stood down for the current
* route (e.g. the export tools). Renders nothing on the billing route itself,
* or for hosts that don't inject a dunning state.
*/
export function DunningBanner() {
const { data: currentUser } = useCurrentUser();
const state = useDunningState();
const takeover = useDunningLockTakeover();
const location = useLocation();

if (!state || takeover || !currentUser || isBillingRoute(location.pathname)) {
return null;
}

const isOwner = isOwnerUser(currentUser);

return (
<Inline
className={cn(
'flex-none border-b px-6 py-2.5',
state.urgent
? 'border-state-danger/40 bg-state-danger/10'
: 'border-state-warning/40 bg-state-warning/10',
)}
data-testid="dunning-banner"
gap="md"
justify="between"
role="alert"
>
<Inline align="center" className="text-sm" gap="sm">
<LucideIcon.TriangleAlert
className={cn(
'size-4 shrink-0',
state.urgent ? 'text-state-danger' : 'text-state-warning',
)}
/>
<span>
<span className="font-semibold">{bannerTitle(state, isOwner)}</span>{' '}
{bannerMessage(state, isOwner)}
</span>
</Inline>
{isOwner && <PayNowButton size="sm" state={state} />}
</Inline>
);
}
57 changes: 57 additions & 0 deletions apps/admin/src/dunning/dunning-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, test } from 'vitest';

import type { DunningState } from './use-dunning-state';
import {
EXPORT_URL,
bannerMessage,
bannerTitle,
daysLeftLabel,
lockedHeadline,
lockedMessage,
} from './dunning-copy';
import { DATA_EXPORT_ROUTE } from './stand-down-routes';

const state = (overrides: Partial<DunningState> = {}): DunningState => ({
phase: 'warning',
daysLeft: 14,
urgent: false,
lockDismissed: false,
paymentFailedAt: new Date('2026-09-01T00:00:00Z'),
suspendsAt: new Date('2026-09-29T00:00:00Z'),
...overrides,
});

describe('dunning copy', () => {
test('the export CTA leads to the route the takeover stands down on', () => {
expect(EXPORT_URL).toBe(`#${DATA_EXPORT_ROUTE}`);
});

test('bannerTitle addresses the reader and escalates with urgency', () => {
expect(bannerTitle(state(), true)).toBe('Your payment didn’t go through.');
expect(bannerTitle(state({ urgent: true }), true)).toBe('Action needed: payment failed.');
expect(bannerTitle(state(), false)).toBe('This site’s payment failed.');
expect(bannerTitle(state({ urgent: true }), false)).toBe('Payment still failing.');
});

test('bannerMessage asks the owner to pay and staff to remind them', () => {
expect(bannerMessage(state(), true)).toMatch(/^Complete your payment/);
expect(bannerMessage(state(), false)).toMatch(/^Remind the site owner/);
});

test('daysLeftLabel handles the singular day', () => {
expect(daysLeftLabel(1)).toBe('1 day');
expect(daysLeftLabel(6)).toBe('6 days');
expect(bannerMessage(state({ daysLeft: 1 }), true)).toMatch(/\(1 day left\)/);
});

test('lockedHeadline counts down to the suspension', () => {
expect(lockedHeadline(6)).toBe('Your site will be suspended in 6 days');
expect(lockedHeadline(1)).toBe('Your site will be suspended tomorrow');
expect(lockedHeadline(0)).toBe('Your site will be suspended soon');
});

test('lockedMessage addresses the reader', () => {
expect(lockedMessage(state(), true)).toMatch(/Pay the outstanding invoice/);
expect(lockedMessage(state(), false)).toMatch(/Remind the site owner/);
});
});
55 changes: 55 additions & 0 deletions apps/admin/src/dunning/dunning-copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { DunningState } from './use-dunning-state';
import { DATA_EXPORT_ROUTE } from './stand-down-routes';

/**
* Destination of the "Pay now" CTA: the billing app's payment page, on its
* return variant — after a successful payment the billing app sends Admin
* back to the page the user came from.
*/
export const PAY_URL = '#/pro/update-card/return';

/** Destination of the "Download my data" CTA: the export tools in settings. */
export const EXPORT_URL = `#${DATA_EXPORT_ROUTE}`;

export function formatDeadline(date: Date): string {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}

export function daysLeftLabel(daysLeft: number): string {
return daysLeft === 1 ? '1 day' : `${daysLeft} days`;
}

export function bannerTitle(state: DunningState, isOwner: boolean): string {
if (isOwner) {
return state.urgent ? 'Action needed: payment failed.' : 'Your payment didn’t go through.';
}
return state.urgent ? 'Payment still failing.' : 'This site’s payment failed.';
}

export function bannerMessage(state: DunningState, isOwner: boolean): string {
const deadline = formatDeadline(state.suspendsAt);
const remaining = daysLeftLabel(state.daysLeft);
if (isOwner) {
return `Complete your payment by ${deadline} to avoid suspension (${remaining} left).`;
}
return `Remind the site owner to pay the outstanding invoice before ${deadline} to avoid suspension (${remaining} left).`;
}

export function lockedHeadline(daysLeft: number): string {
if (daysLeft === 0) {
return 'Your site will be suspended soon';
}
if (daysLeft === 1) {
return 'Your site will be suspended tomorrow';
}
return `Your site will be suspended in ${daysLeft} days`;
}

export function lockedMessage(state: DunningState, isOwner: boolean): string {
const failedOn = formatDeadline(state.paymentFailedAt);
const deadline = formatDeadline(state.suspendsAt);
if (isOwner) {
return `Your last payment failed on ${failedOn} and reminders have gone unanswered. Pay the outstanding invoice before ${deadline} to avoid suspension.`;
}
return `The last payment failed on ${failedOn} and reminders have gone unanswered. Remind the site owner to pay the outstanding invoice before ${deadline} to avoid suspension.`;
}
Loading
Loading