Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
818f548
Added member activity navigation smoke test
peterzimon Sep 14, 2026
1c0a887
Added React activity filters and historical email preview
peterzimon Sep 14, 2026
eed67cb
Added reliable pagination for the full member activity feed
peterzimon Sep 14, 2026
7ef120b
Added the React member activity screen
peterzimon Sep 14, 2026
d337f16
Added a private experiment for React member activity
peterzimon Sep 14, 2026
3badcd4
Fixed activity rollout integration and accessible status announcements
peterzimon Sep 14, 2026
cb3fa6e
Fixed activity filter preservation during the Ember handoff
peterzimon Sep 14, 2026
f9d492f
Added real browser coverage for React activity rollout and pagination
peterzimon Sep 14, 2026
eaec53c
Cleaned up the activity ownership assertion
peterzimon Sep 14, 2026
e6649a6
Fixed SQLite member activity pagination
9larsons Sep 14, 2026
9c5ab71
Merge branch 'main' into codex/pla-285-member-activity-react
peterzimon Sep 15, 2026
92b12b6
Improved React member activity search and filters
peterzimon Sep 15, 2026
d85e6da
Improved member activity layout and typography
peterzimon Sep 15, 2026
921376f
Improved member activity email preview styling
peterzimon Sep 15, 2026
49936a8
Fixed member activity empty state alignment
peterzimon Sep 15, 2026
202940a
Merge branch 'main' into codex/pla-285-member-activity-react
peterzimon Sep 15, 2026
76b2311
Removed member activity README
peterzimon Sep 15, 2026
d445d5e
Merge branch 'main' into codex/pla-285-member-activity-react
peterzimon Sep 15, 2026
824978e
Fixed activity email preview acceptance assertion
peterzimon Sep 15, 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
200 changes: 200 additions & 0 deletions apps/admin-x-framework/src/api/member-activity-pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { escapeNqlString } from '@tryghost/nql-string';
import type { MemberActivityEvent, MemberActivityFeedResponseType } from './members';

type OlderCursor = { kind: 'older'; before?: string };
type BoundaryCursor = {
kind: 'boundary';
timestamp: string;
seen: string[];
completedTypes: string[];
currentType?: string;
beforeId?: string;
};

export type MemberActivityCursor = OlderCursor | BoundaryCursor;
export interface MemberActivityPage extends MemberActivityFeedResponseType {
nextCursor?: MemberActivityCursor;
}

type ReadEvents = (params: Record<string, string>) => Promise<MemberActivityFeedResponseType>;

const eventKey = (event: MemberActivityEvent) => `${event.type}:${event.data.id}`;

// Older Core filters this source as "complained" but returns "complaint".
// Include both spellings so this also works after the server fixes the alias.
const filterTypes = (type: string): string[] =>
type === 'email_complaint_event' ? ['email_complaint_event', 'email_complained_event'] : [type];

function eventTimestamp(event: MemberActivityEvent): string {
const timestamp = event.data.created_at;
if (!timestamp || !Number.isFinite(Date.parse(timestamp))) {
throw new Error('Member activity returned an event without a valid timestamp.');
}
// SQLite compares Ghost's second-precision timestamps as text. Omit a zero
// fraction to match those rows, but retain nonzero milliseconds for precision.
return new Date(timestamp)
.toISOString()
.replace('T', ' ')
.replace(/(?:\.000)?Z$/, '');
}

function validateEvents(events: MemberActivityEvent[]): void {
for (const event of events) {
eventTimestamp(event);
if (typeof event.data.id !== 'string' || !event.data.id || !event.type) {
throw new Error('Member activity returned an event without a valid identity.');
}
}
}

function hasMore(response: MemberActivityFeedResponseType, limit: number): boolean {
const total = response.meta?.pagination.total;
// The total also catches a server applying a lower page limit. Never silently
// treat a clamped response as the end of the feed.
return typeof total === 'number'
? total > response.events.length
: response.events.length >= limit;
}

/**
* The older events API has no cursor or offset and merges several event tables.
* Its same-timestamp ordering is not globally ID-ordered, so adding id < lastId
* to the merged feed would still skip events. Complete its final timestamp one
* event type at a time, where the server's ID-descending order is unambiguous.
* Discover types through the API instead of maintaining a second event catalog.
*
* Requests and returned pages stay bounded by limit. Cursor memory is bounded
* by one page's initial boundary identities and the number of event types,
* regardless of the size of a newsletter send. Shared recipient IDs belonging
* to distinct email event types remain distinct events.
*/
export async function loadMemberActivityPage({
read,
filter = '',
limit,
cursor = { kind: 'older' },
signal,
}: {
read: ReadEvents;
filter?: string;
limit: number;
cursor?: MemberActivityCursor;
signal?: AbortSignal;
}): Promise<MemberActivityPage> {
if (!Number.isInteger(limit) || limit < 1) {
throw new Error('Member activity page size must be a positive integer.');
}
// A failed request must leave the previous page's cursor untouched so a retry
// starts from exactly the same position.
let state: MemberActivityCursor =
cursor.kind === 'boundary'
? { ...cursor, seen: [...cursor.seen], completedTypes: [...cursor.completedTypes] }
: { ...cursor };
const events: MemberActivityEvent[] = [];

const request = async (parts: string[], requestLimit: number) => {
signal?.throwIfAborted();
const response = await read({
// Core requires event-type constraints at the root AND level. Wrapping
// the base filter in parentheses would make mixed type/member filters fail.
filter: [filter, ...parts].filter(Boolean).join('+'),
limit: String(requestLimit),
});
// useFetchApi owns the in-flight network request. Stop any following drain
// requests when React Query cancels this query after navigation/filtering.
signal?.throwIfAborted();
validateEvents(response.events);
if (
response.events.length > requestLimit ||
(!response.events.length && hasMore(response, requestLimit))
) {
throw new Error('Member activity pagination did not make progress.');
}
return response;
};

while (events.length < limit) {
const remaining = limit - events.length;
if (state.kind === 'older') {
const before = state.before;
const response = await request(
before ? [`data.created_at:<${escapeNqlString(before)}`] : [],
remaining,
);
if (before && response.events.some((event) => eventTimestamp(event) >= before)) {
throw new Error('Member activity returned events outside the requested time range.');
}
events.push(...response.events);
if (!hasMore(response, remaining)) {
return { events, meta: response.meta };
}
const timestamp = eventTimestamp(response.events[response.events.length - 1]);
state = {
kind: 'boundary',
timestamp,
seen: response.events.filter((event) => eventTimestamp(event) === timestamp).map(eventKey),
completedTypes: [],
};
if (events.length === limit) {
return { events, meta: response.meta, nextCursor: state };
}
continue;
}

const timestampFilter = `data.created_at:${escapeNqlString(state.timestamp)}`;
if (!state.currentType) {
const typeFilter = state.completedTypes.length
? [`type:-[${state.completedTypes.flatMap(filterTypes).map(escapeNqlString).join(',')}]`]
: [];
const discovery = await request([timestampFilter, ...typeFilter], 1);
const event = discovery.events[0];
if (
!event ||
(!state.completedTypes.length && discovery.meta?.pagination.total === state.seen.length)
) {
state = { kind: 'older', before: state.timestamp };
continue;
}
if (eventTimestamp(event) !== state.timestamp || state.completedTypes.includes(event.type)) {
throw new Error('Member activity returned events outside the requested type or timestamp.');
}
state.currentType = event.type;
}

const beforeId = state.beforeId;
const currentType = state.currentType;
const timestamp = state.timestamp;
const response = await request(
[
timestampFilter,
`type:[${filterTypes(currentType).map(escapeNqlString).join(',')}]`,
...(beforeId ? [`id:<${escapeNqlString(beforeId)}`] : []),
],
remaining,
);
if (
response.events.some(
(event) =>
event.type !== currentType ||
eventTimestamp(event) !== timestamp ||
(beforeId && String(event.data.id) >= beforeId),
)
) {
throw new Error('Member activity returned events outside the requested cursor.');
}
const seen = new Set(state.seen);
events.push(...response.events.filter((event) => !seen.has(eventKey(event))));

if (hasMore(response, remaining)) {
// The single-type response is ID-descending. Use the minimum defensively
// so the cursor advances even if an older server returns a shuffled page.
state.beforeId = response.events.map((event) => String(event.data.id)).sort()[0];
} else {
state.completedTypes.push(state.currentType);
state.currentType = undefined;
state.beforeId = undefined;
}
}

return { events, nextCursor: state };
}
72 changes: 72 additions & 0 deletions apps/admin-x-framework/src/api/member-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useEffect, useMemo } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { escapeNqlString } from '@tryghost/nql-string';
import useHandleError from '../hooks/use-handle-error';
import { apiUrl, useFetchApi } from '../utils/api/fetch-api';
import { loadMemberActivityPage, type MemberActivityCursor } from './member-activity-pagination';
import type { MemberActivityFeedResponseType } from './members';

export interface BrowseMemberActivityOptions {
memberId?: string;
excludedEvents?: string[];
limit?: number;
enabled?: boolean;
defaultErrorHandler?: boolean;
}

/** Full, paginated activity feed. The member detail preview keeps its five-row query. */
export function useBrowseMemberActivityFeed({
memberId,
excludedEvents = [],
limit = 50,
enabled = true,
defaultErrorHandler = true,
}: BrowseMemberActivityOptions = {}) {
const fetchApi = useFetchApi();
const handleError = useHandleError();
// Accept the Activity page's domain filters, not arbitrary NQL. Core splits
// type selectors from row filters, so root-OR expressions cannot safely be
// combined with a timestamp or per-type pagination cursor.
const baseFilter = [
excludedEvents.length && `type:-[${excludedEvents.map(escapeNqlString).join(',')}]`,
memberId && `data.member_id:${escapeNqlString(memberId)}`,
]
.filter(Boolean)
.join('+');
const result = useInfiniteQuery({
queryKey: [
'MemberActivityFeedResponseType',
apiUrl('/members/events/', { filter: baseFilter, limit: String(limit) }),
'timeline',
],
enabled,
initialPageParam: { kind: 'older' } as MemberActivityCursor,
queryFn: ({ pageParam, signal }) =>
loadMemberActivityPage({
read: (params) =>
fetchApi<MemberActivityFeedResponseType>(apiUrl('/members/events/', params)),
filter: baseFilter,
limit,
cursor: pageParam,
signal,
}),
getNextPageParam: (page) => page.nextCursor,
});
const data = useMemo(
() =>
result.data && {
events: result.data.pages.flatMap((page) => page.events),
meta: result.data.pages[0]?.meta,
isEnd: !result.data.pages[result.data.pages.length - 1]?.nextCursor,
},
[result.data],
);

useEffect(() => {
if (result.error && defaultErrorHandler) {
handleError(result.error);
}
}, [result.error, handleError, defaultErrorHandler]);

return { ...result, data };
}
8 changes: 5 additions & 3 deletions apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { useCurrentUser } from './current-user';
import { canManageMembers } from './users';
import { FREE_SEGMENT, PAID_SEGMENT } from '../utils/recipient-filter';

export { useBrowseMemberActivityFeed, type BrowseMemberActivityOptions } from './member-activity';

export type MemberLabel = {
id: string;
name: string;
Expand Down Expand Up @@ -857,11 +859,11 @@ const MEMBER_ACTIVITY_LIMIT = '20';
// last event of the previous page (events are ordered created_at desc).
//
// KNOWN LIMITATION: the cursor is `created_at`-only, without the id tie-breaker
// Ember's version added (`+id:<'<lastId>'`). Two events emitted in the same
// required for reliable pagination. Two events emitted in the same
// second on a page boundary can be skipped from the paginated list. The current
// consumer (`MemberActivityFeed` in `apps/admin`) only fetches 5 events and
// never calls `fetchNextPage`, so this is not exploitable today; add an id
// secondary cursor before another screen starts paginating.
// never calls `fetchNextPage`. Paginated consumers must use
// useBrowseMemberActivityFeed, which drains timestamp boundaries by event type.
function memberEventsCursor(events: MemberActivityEvent[]): string | undefined {
const createdAt = events[events.length - 1]?.data?.created_at;
if (!createdAt) {
Expand Down
Loading
Loading