Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .changeset/changelogs/@tryghost!kg-default-cards@10.3.6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## 10.3.6

### Patch Changes

- Updated @tryghost/url-utils to 5.2.9

- Updated juice to 11.1.1

- Updated @tryghost/url-utils to 5.3.0 so URLs containing HTML entities are transformed
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## 1.5.1

### Patch Changes

- Updated dependencies
5 changes: 0 additions & 5 deletions .changeset/changelogs/@tryghost!koenig-lexical@1.10.0.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/koenig-style-export.md

This file was deleted.

10 changes: 10 additions & 0 deletions .changeset/ledger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@
intents:
- common-squids-argue
- six-parents-shout
"@tryghost/kg-default-cards@10.3.6":
dir: koenig/kg-default-cards
intents:
- early-elephants-press
- plain-parsers-share
- tidy-sdk-bump-cards
"@tryghost/kg-default-nodes@2.1.4":
dir: koenig/kg-default-nodes
intents:
Expand Down Expand Up @@ -97,6 +103,10 @@
intents:
- common-squids-argue
- six-parents-shout
"@tryghost/kg-lexical-html-renderer@1.5.1":
dir: koenig/kg-lexical-html-renderer
intents:
- tame-shirts-float
"@tryghost/kg-markdown-html-renderer@7.2.4":
dir: koenig/kg-markdown-html-renderer
intents:
Expand Down
6 changes: 0 additions & 6 deletions .changeset/quiet-sdk-bump-nodes.md

This file was deleted.

6 changes: 0 additions & 6 deletions .changeset/weak-wasps-love.md

This file was deleted.

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