Skip to content

Commit f55bf9f

Browse files
Added multiple-active-subscriptions members warning and filter (#28232)
ref https://linear.app/ghost/issue/BER-3717/ - added custom backend members filter for members with active subscriptions across multiple Stripe customers - added check and banner on members screen load for any members in the multiple-active-subscription state - banner shows if any members are in the potentially bad state - banner can be dismissed and will not show again unless the number of multiple-active-subscription members grows - banner contains a link to the custom members filter that lists only members with multiple active subscriptions --------- Co-authored-by: Peter Zimon <peter.zimon@gmail.com>
1 parent 51ff418 commit f55bf9f

21 files changed

Lines changed: 742 additions & 29 deletions

apps/posts/src/views/members/components/members-actions.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, {useCallback, useState} from 'react';
22
import {AddLabelModal, DeleteModal, ImportMembersModal, RemoveLabelModal, UnsubscribeModal} from './bulk-action-modals';
33
import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger} from '@tryghost/shade/components';
44
import {type ImportResponse} from './bulk-action-modals/import-members/state';
5-
import {LucideIcon} from '@tryghost/shade/utils';
5+
import {LucideIcon, formatNumber} from '@tryghost/shade/utils';
66
import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers';
77
import {buildMemberOperationParams} from '../member-query-params';
88
import {buildMembersUrl} from '../member-route';
@@ -229,25 +229,25 @@ const MembersActions: React.FC<MembersActionsProps> = ({
229229
<DropdownMenuItem onClick={handleExport}>
230230
<LucideIcon.Download className="mr-2 size-4" />
231231
{hasFilterOrSearch
232-
? `Export ${memberCount.toLocaleString()} members`
232+
? `Export ${formatNumber(memberCount)} members`
233233
: 'Export all members'}
234234
</DropdownMenuItem>
235235

236236
<DropdownMenuSeparator />
237237
<DropdownMenuItem onClick={() => setShowAddLabelModal(true)}>
238238
<LucideIcon.Tags className="mr-2 size-4" />
239-
Add label to {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
239+
Add label to {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
240240
</DropdownMenuItem>
241241
<DropdownMenuItem onClick={() => setShowRemoveLabelModal(true)}>
242242
<LucideIcon.Tag className="mr-2 size-4" />
243-
Remove label from {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
243+
Remove label from {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
244244
</DropdownMenuItem>
245245
<DropdownMenuItem
246246
disabled={isLoadingNewsletters}
247247
onClick={() => setShowUnsubscribeModal(true)}
248248
>
249249
<LucideIcon.MailX className="mr-2 size-4" />
250-
Unsubscribe {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
250+
Unsubscribe {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
251251
</DropdownMenuItem>
252252
<DropdownMenuSeparator />
253253
<DropdownMenuItem
@@ -256,7 +256,7 @@ const MembersActions: React.FC<MembersActionsProps> = ({
256256
onClick={() => setShowDeleteModal(true)}
257257
>
258258
<LucideIcon.Trash2 className="mr-2 size-4" />
259-
Delete {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
259+
Delete {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
260260
</DropdownMenuItem>
261261
</>
262262
)}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {Banner} from '@tryghost/shade/components';
2+
import {formatNumber} from '@tryghost/shade/utils';
3+
import {useMultipleActiveSubscriptionsBanner} from '../hooks/use-multiple-active-subscriptions-banner';
4+
5+
interface MultipleActiveSubscriptionsBannerProps {
6+
nql?: string;
7+
search: string;
8+
}
9+
10+
const MultipleActiveSubscriptionsBanner = ({
11+
nql,
12+
search
13+
}: MultipleActiveSubscriptionsBannerProps) => {
14+
const banner = useMultipleActiveSubscriptionsBanner({
15+
nql,
16+
search
17+
});
18+
19+
if (!banner.shouldShow) {
20+
return null;
21+
}
22+
23+
return (
24+
<Banner
25+
role="status"
26+
variant="warning"
27+
{...(banner.canDismiss ? {
28+
dismissible: true as const,
29+
onDismiss: banner.handleDismiss
30+
} : {
31+
dismissible: false as const
32+
})}
33+
>
34+
<div className="flex flex-col items-baseline gap-3 pr-8 sm:flex-row">
35+
We found {formatNumber(banner.count)} {banner.count === 1 ? 'member' : 'members'} with more than one active paid subscription.{' '}
36+
<div className="flex items-baseline gap-3">
37+
{banner.canDismiss && (
38+
<button className="nowrap font-semibold !underline" type="button" onClick={banner.handleViewMembers}>
39+
View members
40+
</button>
41+
)}
42+
<a className="nowrap font-semibold underline" href="https://ghost.org/help/duplicate-subscription-warning/" rel="noopener noreferrer" target="_blank">Learn more</a>
43+
</div>
44+
</div>
45+
</Banner>
46+
);
47+
};
48+
49+
export default MultipleActiveSubscriptionsBanner;

apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,57 @@ describe('useMembersFilterState', () => {
5757
expect(result.current.hasFilterOrSearch).toBe(false);
5858
});
5959

60+
it('parses the multiple active Stripe customers filter into a predicate', async () => {
61+
const {result} = renderHook(() => {
62+
const state = useMembersFilterState('UTC');
63+
const [searchParams] = useSearchParams();
64+
65+
return {
66+
...state,
67+
query: searchParams.toString()
68+
};
69+
}, {
70+
wrapper: createWrapper('/?filter=count.active_stripe_customers%3A%3E1')
71+
});
72+
73+
await waitFor(() => {
74+
expect(result.current.nql).toBe('count.active_stripe_customers:>1');
75+
});
76+
77+
expect(result.current.filters).toEqual([
78+
{
79+
id: 'count.active_stripe_customers:1',
80+
field: 'count.active_stripe_customers',
81+
operator: 'is-greater',
82+
values: [1]
83+
}
84+
]);
85+
expect(result.current.query).toBe('filter=count.active_stripe_customers%3A%3E1');
86+
expect(result.current.hasFilterOrSearch).toBe(true);
87+
});
88+
89+
it('drops unsupported multiple active Stripe customers filter values', async () => {
90+
const {result} = renderHook(() => {
91+
const state = useMembersFilterState('UTC');
92+
const [searchParams] = useSearchParams();
93+
94+
return {
95+
...state,
96+
query: searchParams.toString()
97+
};
98+
}, {
99+
wrapper: createWrapper('/?filter=count.active_stripe_customers%3A%3E2')
100+
});
101+
102+
await waitFor(() => {
103+
expect(result.current.query).toBe('');
104+
});
105+
106+
expect(result.current.filters).toEqual([]);
107+
expect(result.current.nql).toBeUndefined();
108+
expect(result.current.hasFilterOrSearch).toBe(false);
109+
});
110+
60111
it('retains supported filters and rewrites mixed URLs canonically', async () => {
61112
const {result} = renderHook(() => {
62113
const state = useMembersFilterState('UTC');

apps/posts/src/views/members/hooks/use-members-filter-state.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,13 @@ export function shouldDelayMembersDateFilterHydration(
4343
return Boolean(filterParam) && isLoadingDependencies && !hasResolvedDependencies && hasTimezoneSensitiveMemberFilter(filterParam);
4444
}
4545

46+
function getEnabledFilters(filters: Filter[], fields: MemberFields): Filter[] {
47+
return filters.filter(predicate => isPredicateEnabled(predicate, fields));
48+
}
49+
4650
function toSearchParams({baseSearchParams, filters, search, timezone, fields}: ToSearchParamsOptions): URLSearchParams {
4751
const params = new URLSearchParams(baseSearchParams);
48-
const enabled = filters.filter(predicate => isPredicateEnabled(predicate, fields));
49-
const filter = serializeMemberFilters(enabled, timezone);
52+
const filter = serializeMemberFilters(getEnabledFilters(filters, fields), timezone);
5053

5154
params.delete('filter');
5255
params.delete('search');
@@ -70,7 +73,7 @@ export function useMembersFilterState(timezone: string): UseMembersFilterStateRe
7073
const currentQuery = useMemo(() => searchParams.toString(), [searchParams]);
7174

7275
const parsedFilters = useMemo(() => {
73-
return parseMemberFilter(filterParam, timezone).filter(predicate => isPredicateEnabled(predicate, fields));
76+
return getEnabledFilters(parseMemberFilter(filterParam, timezone), fields);
7477
}, [filterParam, timezone, fields]);
7578
const [filters, setDraftFilters] = useState<Filter[]>(parsedFilters);
7679

@@ -79,8 +82,7 @@ export function useMembersFilterState(timezone: string): UseMembersFilterStateRe
7982
}, [searchParams]);
8083

8184
const nql = useMemo(() => {
82-
const enabled = filters.filter(predicate => isPredicateEnabled(predicate, fields));
83-
return serializeMemberFilters(enabled, timezone);
85+
return serializeMemberFilters(getEnabledFilters(filters, fields), timezone);
8486
}, [filters, timezone, fields]);
8587

8688
useEffect(() => {
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {
2+
MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER,
3+
buildUserWithDismissedMultipleActiveSubscriptionsBanner,
4+
getMultipleActiveSubscriptionsBannerPreference,
5+
isMultipleActiveSubscriptionsFilter
6+
} from '../multiple-active-subscriptions';
7+
import {buildMembersUrl} from '../member-route';
8+
import {canManageMembers, useEditUser} from '@tryghost/admin-x-framework/api/users';
9+
import {toast} from 'sonner';
10+
import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members';
11+
import {useCallback, useEffect, useMemo, useState} from 'react';
12+
import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user';
13+
import {useNavigate} from 'react-router';
14+
15+
interface UseMultipleActiveSubscriptionsBannerOptions {
16+
nql?: string;
17+
search: string;
18+
}
19+
20+
/**
21+
* Drives the banner warning that some members have active subscriptions across
22+
* multiple Stripe customers. Dismissal is stored per-user as the member count
23+
* at dismissal time, so the banner stays hidden until the count grows beyond
24+
* what the user last acknowledged.
25+
*/
26+
export function useMultipleActiveSubscriptionsBanner({
27+
nql,
28+
search
29+
}: UseMultipleActiveSubscriptionsBannerOptions) {
30+
const navigate = useNavigate();
31+
const {data: currentUser} = useCurrentUser();
32+
const {mutateAsync: editUser, isLoading: isDismissing} = useEditUser();
33+
const [optimisticDismissedCount, setOptimisticDismissedCount] = useState<number | null>(null);
34+
35+
const canManageMemberList = currentUser ? canManageMembers(currentUser) : false;
36+
const isViewingFilter = isMultipleActiveSubscriptionsFilter(nql);
37+
// Only relevant on the unfiltered member list or when viewing the
38+
// affected members themselves — any other filter/search hides the banner.
39+
const shouldConsiderBanner = !search && (!nql || isViewingFilter);
40+
41+
// Count-only query: we just need pagination.total, not the members.
42+
const {
43+
data
44+
} = useBrowseMembers({
45+
searchParams: {
46+
filter: MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER,
47+
limit: '1',
48+
fields: 'id',
49+
order: 'id'
50+
},
51+
defaultErrorHandler: false,
52+
enabled: canManageMemberList && shouldConsiderBanner,
53+
refetchOnMount: 'always',
54+
staleTime: 0
55+
});
56+
57+
const count = data?.meta?.pagination?.total ?? 0;
58+
const preference = useMemo(() => {
59+
return getMultipleActiveSubscriptionsBannerPreference(currentUser?.accessibility);
60+
}, [currentUser?.accessibility]);
61+
const dismissedCount = optimisticDismissedCount ?? preference.dismissedCount ?? 0;
62+
// While viewing the filtered list the banner explains what's being shown,
63+
// so it can't be dismissed and ignores any previous dismissal.
64+
const canDismiss = !isViewingFilter;
65+
const shouldShow = shouldConsiderBanner
66+
&& (
67+
isViewingFilter
68+
|| count > dismissedCount
69+
);
70+
71+
// When the member count shrinks below the stored dismissal count, lower the
72+
// stored count to match — otherwise fixing some members would leave enough
73+
// headroom for new occurrences to go unnoticed until the old high-water
74+
// mark is passed again.
75+
useEffect(() => {
76+
const storedDismissedCount = preference.dismissedCount;
77+
78+
if (
79+
!currentUser
80+
|| optimisticDismissedCount !== null
81+
|| isDismissing
82+
|| storedDismissedCount === undefined
83+
|| data === undefined
84+
|| count >= storedDismissedCount
85+
) {
86+
return;
87+
}
88+
89+
setOptimisticDismissedCount(count);
90+
91+
editUser(buildUserWithDismissedMultipleActiveSubscriptionsBanner(
92+
currentUser,
93+
count,
94+
preference.dismissedAt ?? new Date().toISOString()
95+
)).then(() => {
96+
setOptimisticDismissedCount(null);
97+
}).catch((error) => {
98+
setOptimisticDismissedCount(null);
99+
// This keeps the preference in sync opportunistically; failing to sync should not interrupt the member list.
100+
// eslint-disable-next-line no-console
101+
console.log('Unable to update multiple active subscriptions banner dismissed count', error);
102+
});
103+
}, [
104+
count,
105+
currentUser,
106+
data,
107+
editUser,
108+
isDismissing,
109+
optimisticDismissedCount,
110+
preference.dismissedAt,
111+
preference.dismissedCount
112+
]);
113+
114+
// Hides the banner immediately via optimistic state, then persists the
115+
// current count to the user's accessibility preferences.
116+
const handleDismiss = useCallback(() => {
117+
if (!currentUser || isDismissing) {
118+
return;
119+
}
120+
121+
const previousDismissedCount = optimisticDismissedCount;
122+
123+
setOptimisticDismissedCount(count);
124+
125+
editUser(buildUserWithDismissedMultipleActiveSubscriptionsBanner(
126+
currentUser,
127+
count,
128+
new Date().toISOString()
129+
)).then(() => {
130+
setOptimisticDismissedCount(null);
131+
}).catch(() => {
132+
setOptimisticDismissedCount(previousDismissedCount);
133+
toast.error('Unable to dismiss notification. Please try again.');
134+
});
135+
}, [count, currentUser, editUser, isDismissing, optimisticDismissedCount]);
136+
137+
const handleViewMembers = useCallback(() => {
138+
navigate(buildMembersUrl({filter: MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER}));
139+
}, [navigate]);
140+
141+
return {
142+
canDismiss,
143+
count,
144+
handleDismiss,
145+
handleViewMembers,
146+
shouldShow
147+
};
148+
}

apps/posts/src/views/members/member-fields.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ describe('memberFields', () => {
4141
'opened_emails.post_id',
4242
'clicked_links.post_id',
4343
'newsletter_feedback',
44-
'offer_redemptions'
44+
'offer_redemptions',
45+
'count.active_stripe_customers'
4546
]);
4647
});
4748

0 commit comments

Comments
 (0)