diff --git a/apps/admin-x-framework/src/api/member-activity-pagination.ts b/apps/admin-x-framework/src/api/member-activity-pagination.ts new file mode 100644 index 00000000000..9b59bbb75c1 --- /dev/null +++ b/apps/admin-x-framework/src/api/member-activity-pagination.ts @@ -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) => Promise; + +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 { + 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 }; +} diff --git a/apps/admin-x-framework/src/api/member-activity.ts b/apps/admin-x-framework/src/api/member-activity.ts new file mode 100644 index 00000000000..854858c4aef --- /dev/null +++ b/apps/admin-x-framework/src/api/member-activity.ts @@ -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(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 }; +} diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index fe1ade0350a..e3108006af1 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -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; @@ -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:<''`). 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) { diff --git a/apps/admin-x-framework/test/unit/api/member-activity-pagination.test.ts b/apps/admin-x-framework/test/unit/api/member-activity-pagination.test.ts new file mode 100644 index 00000000000..1a09f32430f --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/member-activity-pagination.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + loadMemberActivityPage, + type MemberActivityCursor, +} from '../../../src/api/member-activity-pagination'; +import type { MemberActivityEvent, MemberActivityFeedResponseType } from '../../../src/api/members'; + +const timestamp = '2026-09-14T10:00:00.000Z'; +const olderTimestamp = '2026-09-14T09:59:59.000Z'; + +function event( + id: number, + type = 'email_delivered_event', + createdAt = timestamp, + memberId = 'member-1', +): MemberActivityEvent { + return { + type, + data: { id: String(id).padStart(24, '0'), created_at: createdAt, member_id: memberId }, + }; +} + +const identity = (value: MemberActivityEvent) => `${value.type}:${value.data.id}`; +const serverFilterType = (value: MemberActivityEvent) => + value.type === 'email_complaint_event' ? 'email_complained_event' : value.type; + +/** A legacy server: only filter+limit, timestamp order, and per-type ID order. + * Mixed types deliberately use type priority ahead of ID, as Core does. */ +function legacyServer( + source: MemberActivityEvent[], + { metadata = true, maxLimit = Infinity, textTimestamps = false } = {}, +) { + return vi.fn( + async ({ filter, limit }: Record): Promise => { + let matching = [...source]; + for (const match of filter.matchAll(/data\.created_at:(<)?'([^']+)'/g)) { + const cursor = textTimestamps ? match[2] : Date.parse(match[2].replace(' ', 'T') + 'Z'); + matching = matching.filter((value) => { + // SQLite compares the second-precision text Ghost stores, without + // converting the filter value to a date as MySQL does. + const stored = textTimestamps + ? value.data.created_at!.replace('T', ' ').replace(/\.000Z$/, '') + : Date.parse(value.data.created_at!); + return match[1] ? stored < cursor : stored === cursor; + }); + } + for (const match of filter.matchAll(/type:-\[([^\]]+)\]/g)) { + const excluded = match[1].replaceAll("'", '').split(','); + matching = matching.filter((value) => !excluded.includes(serverFilterType(value))); + } + for (const match of filter.matchAll(/type:'([^']+)'/g)) { + matching = matching.filter((value) => serverFilterType(value) === match[1]); + } + for (const match of filter.matchAll(/type:\[([^\]]+)\]/g)) { + const included = match[1].replaceAll("'", '').split(','); + matching = matching.filter((value) => included.includes(serverFilterType(value))); + } + for (const match of filter.matchAll(/data\.member_id:'([^']+)'/g)) { + matching = matching.filter((value) => value.data.member_id === match[1]); + } + for (const match of filter.matchAll(/(?:^|\+)id:<'([^']+)'/g)) { + matching = matching.filter((value) => String(value.data.id) < match[1]); + } + matching.sort( + (a, b) => + Date.parse(b.data.created_at!) - Date.parse(a.data.created_at!) || + a.type.localeCompare(b.type) || + String(b.data.id).localeCompare(String(a.data.id)), + ); + const pageLimit = Math.min(Number(limit), maxLimit); + return { + events: matching.slice(0, pageLimit), + ...(metadata + ? { + meta: { + pagination: { + limit: pageLimit, + total: matching.length, + page: 1, + pages: Math.ceil(matching.length / pageLimit), + next: null, + prev: null, + }, + }, + } + : {}), + }; + }, + ); +} + +async function browseAll( + read: ReturnType, + options: { limit?: number; filter?: string } = {}, +) { + let cursor: MemberActivityCursor | undefined; + const events: MemberActivityEvent[] = []; + for (let pageCount = 0; pageCount < 1000; pageCount++) { + const page = await loadMemberActivityPage({ read, limit: 50, ...options, cursor }); + expect(page.events.length).toBeLessThanOrEqual(options.limit ?? 50); + events.push(...page.events); + cursor = page.nextCursor; + if (!cursor) { + return events; + } + } + throw new Error('Pagination failed to finish.'); +} + +describe('member activity pagination against the legacy endpoint', () => { + it('returns every event in a large same-second send with bounded requests and no duplicate identities', async () => { + const source = [ + ...Array.from({ length: 2500 }, (_, index) => event(index + 1)), + ...Array.from({ length: 60 }, (_, index) => + event(index + 2501, 'email_delivered_event', olderTimestamp), + ), + ]; + const read = legacyServer(source); + const result = await browseAll(read); + + expect(result.map(identity)).toEqual( + [...source] + .sort( + (a, b) => + Date.parse(b.data.created_at!) - Date.parse(a.data.created_at!) || + String(b.data.id).localeCompare(String(a.data.id)), + ) + .map(identity), + ); + expect(new Set(result.map(identity)).size).toBe(source.length); + expect(read.mock.calls.every(([params]) => Number(params.limit) <= 50)).toBe(true); + expect(Math.max(...read.mock.calls.map(([params]) => params.filter.length))).toBeLessThan(250); + }); + + it('keeps events of different types with the same recipient ID and discovers unknown types', async () => { + const source = [ + ...Array.from({ length: 80 }, (_, index) => event(index + 1, 'email_delivered_event')), + ...Array.from({ length: 80 }, (_, index) => event(index + 1, 'email_opened_event')), + ...Array.from({ length: 70 }, (_, index) => event(index + 1, 'future_server_event')), + event(900, 'signup_event', olderTimestamp), + ]; + const result = await browseAll(legacyServer(source), { limit: 7 }); + + expect(result.map(identity).sort()).toEqual(source.map(identity).sort()); + expect(result[result.length - 1]).toEqual(source[source.length - 1]); + expect(new Set(result.map(identity)).size).toBe(source.length); + }); + + it('preserves exclusions and member constraints for every boundary request', async () => { + const source = [ + ...Array.from({ length: 70 }, (_, index) => event(index + 1)), + ...Array.from({ length: 70 }, (_, index) => event(index + 1, 'email_opened_event')), + ...Array.from({ length: 70 }, (_, index) => + event(index + 1, 'email_delivered_event', timestamp, 'member-2'), + ), + ]; + const filter = "type:-[email_opened_event]+data.member_id:'member-1'"; + const read = legacyServer(source); + const result = await browseAll(read, { limit: 10, filter }); + + expect(result.map(identity).sort()).toEqual(source.slice(0, 70).map(identity).sort()); + expect(read.mock.calls.every(([params]) => params.filter.startsWith(filter))).toBe(true); + }); + + it('drains spam complaints when Core uses a different type name in its filter', async () => { + const source = [ + ...Array.from({ length: 70 }, (_, index) => event(index + 1, 'email_complaint_event')), + ...Array.from({ length: 70 }, (_, index) => event(index + 1, 'email_opened_event')), + ]; + const result = await browseAll(legacyServer(source), { limit: 7 }); + expect(result.map(identity).sort()).toEqual(source.map(identity).sort()); + }); + + it('does not require pagination metadata from an older backend', async () => { + const source = Array.from({ length: 105 }, (_, index) => event(index + 1)); + expect( + (await browseAll(legacyServer(source, { metadata: false }))).map(identity).sort(), + ).toEqual(source.map(identity).sort()); + }); + + it('continues when a server clamps the requested page size', async () => { + const source = Array.from({ length: 105 }, (_, index) => event(index + 1)); + expect((await browseAll(legacyServer(source, { maxLimit: 7 }))).map(identity).sort()).toEqual( + source.map(identity).sort(), + ); + }); + + it.each([1, 75])( + 'paginates SQLite text timestamps with %s events at the boundary', + async (boundaryCount) => { + const source = [ + ...Array.from({ length: boundaryCount }, (_, index) => event(index + 1)), + event(100, 'login_event', olderTimestamp), + ]; + const result = await browseAll(legacyServer(source, { textTimestamps: true }), { + limit: Math.min(boundaryCount, 50), + }); + + expect(result.map(identity).sort()).toEqual(source.map(identity).sort()); + expect(result[result.length - 1]).toEqual(source[source.length - 1]); + expect(new Set(result.map(identity)).size).toBe(source.length); + }, + ); + + it('keeps sub-second timestamp precision across page boundaries', async () => { + const source = [ + event(3, 'login_event', '2026-09-14T10:00:00.900Z'), + event(2, 'login_event', '2026-09-14T10:00:00.500Z'), + event(1, 'login_event', '2026-09-14T10:00:00.100Z'), + event(0, 'login_event', '2026-09-14T10:00:00.000Z'), + ]; + expect(await browseAll(legacyServer(source), { limit: 1 })).toEqual(source); + }); + + it('retries a failed boundary request from the unchanged cursor', async () => { + const source = Array.from({ length: 125 }, (_, index) => event(index + 1)); + const read = legacyServer(source); + const first = await loadMemberActivityPage({ read, limit: 50 }); + const savedCursor = JSON.stringify(first.nextCursor); + const reliableRead = read.getMockImplementation()!; + read.mockImplementationOnce(reliableRead).mockRejectedValueOnce(new Error('Connection lost')); + + await expect( + loadMemberActivityPage({ read, limit: 50, cursor: first.nextCursor }), + ).rejects.toThrow('Connection lost'); + expect(JSON.stringify(first.nextCursor)).toBe(savedCursor); + const second = await loadMemberActivityPage({ read, limit: 50, cursor: first.nextCursor }); + const third = await loadMemberActivityPage({ read, limit: 50, cursor: second.nextCursor }); + expect([...first.events, ...second.events, ...third.events].map(identity).sort()).toEqual( + source.map(identity).sort(), + ); + }); + + it('stops subsequent boundary requests when a filter change cancels the query', async () => { + const source = Array.from({ length: 125 }, (_, index) => event(index + 1)); + const read = legacyServer(source); + const first = await loadMemberActivityPage({ read, limit: 50 }); + const controller = new AbortController(); + const reliableRead = read.getMockImplementation()!; + read.mockImplementationOnce(async (params) => { + const response = await reliableRead(params); + controller.abort(); + return response; + }); + const beforeCalls = read.mock.calls.length; + + await expect( + loadMemberActivityPage({ + read, + limit: 50, + cursor: first.nextCursor, + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(read.mock.calls.length - beforeCalls).toBe(1); + }); + + it('fails visibly instead of ending pagination when a response cannot advance', async () => { + const read = legacyServer([event(1)]); + read.mockResolvedValueOnce({ + events: [], + meta: { pagination: { page: 1, pages: 2, limit: 50, total: 100, next: null, prev: null } }, + }); + await expect(loadMemberActivityPage({ read, limit: 50 })).rejects.toThrow( + 'did not make progress', + ); + }); +}); diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index e4446672206..492eb2a9d40 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -88,6 +88,7 @@ function NavContent({ ...props }: React.ComponentProps) { const routing = useEmberRouting(); const automationsEnabled = useFeatureFlag('automations'); const isMembersRouteActive = useIsActiveLink({ path: 'members', activeOnSubpath: true }); + const isMemberActivityActive = useIsActiveLink({ path: 'members-activity' }); const showTags = currentUser && canManageTags(currentUser); const showMembers = currentUser && canManageMembers(currentUser); @@ -101,7 +102,7 @@ function NavContent({ ...props }: React.ComponentProps) { const membersExpanded = savedMembersExpanded; const membersNavActive = isMembersRouteActive ? !hasActiveMemberView || !membersExpanded - : routing.isRouteActive(LEGACY_MEMBERS_ACTIVE_ROUTES); + : isMemberActivityActive || routing.isRouteActive(LEGACY_MEMBERS_ACTIVE_ROUTES); const postsRoute = postNavigation.mainUrl; const postsNavActive = postNavigation.isMainActive || (!postsExpanded && hasActivePostChild); return ( diff --git a/apps/admin/src/member-activity-gate.acceptance.test.tsx b/apps/admin/src/member-activity-gate.acceptance.test.tsx new file mode 100644 index 00000000000..d939a3e8be3 --- /dev/null +++ b/apps/admin/src/member-activity-gate.acceptance.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import { configResponse, fakeAdminEndpoint, renderAdminApp } from '@test-utils/acceptance'; + +describe('Member activity route ownership', () => { + it.each([false, undefined])('leaves the page with Ember when the flag is %s', async (enabled) => { + const config = configResponse(); + config.config.labs ??= {}; + if (enabled === undefined) { + delete config.config.labs.membersActivityReact; + } else { + config.config.labs.membersActivityReact = enabled; + } + const events = fakeAdminEndpoint('GET', /^\/members\/events\//, { events: [] }); + await renderAdminApp('/members-activity', { + boot: { browseConfig: { response: config } }, + }); + + // There is no Ember runtime in this tier; the shell exposes its host + // instead. The real off-flag UI journey is covered by browser E2E. + await expect + .poll(() => document.getElementById('ember-app')?.parentElement?.hidden) + .toBe(false); + await expect + .element(page.getByRole('heading', { name: 'Member activity' })) + .not.toBeInTheDocument(); + expect(events.requests).toHaveLength(0); + }); +}); diff --git a/apps/admin/src/member-activity-gate.tsx b/apps/admin/src/member-activity-gate.tsx new file mode 100644 index 00000000000..64a370e7b65 --- /dev/null +++ b/apps/admin/src/member-activity-gate.tsx @@ -0,0 +1,9 @@ +import { lazy } from 'react'; +import { FlagGatedRoute } from './flag-gated-route'; +import { lazyMemberActivityScreen } from './members/api'; + +const MemberActivityReact = lazy(lazyMemberActivityScreen); + +export function MemberActivityGate() { + return ; +} diff --git a/apps/admin/src/members/activity/activity-email-preview-data.ts b/apps/admin/src/members/activity/activity-email-preview-data.ts new file mode 100644 index 00000000000..0594ef11edc --- /dev/null +++ b/apps/admin/src/members/activity/activity-email-preview-data.ts @@ -0,0 +1,68 @@ +import type { Config } from '@tryghost/admin-x-framework/api/config'; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function string(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** Activity carries historical email objects, occasionally nested in a post. */ +export function activityEmailPreviewData(value: unknown) { + const source = record(value); + const nested = record(source?.email); + const stored = [source, nested].find( + (candidate) => string(candidate?.html) && typeof candidate?.subject === 'string', + ); + const newsletter = record(source?.newsletter) ?? record(nested?.newsletter); + // An email id is not a post id. Only actual post-shaped objects may fall + // back to their own id; welcome/automation ids must never hit this endpoint. + const postId = + string(source?.post_id) || + string(nested?.post_id) || + (typeof source?.title === 'string' ? string(source.id) : undefined); + + return { + stored: stored ? { html: string(stored.html)!, subject: string(stored.subject)! } : undefined, + postId, + newsletter: newsletter + ? { + sender_name: string(newsletter.sender_name), + sender_email: string(newsletter.sender_email), + } + : undefined, + }; +} + +/** Matches Ember's sender-email-address helper, including managed domains. */ +export function activitySenderAddress( + sender: string | undefined, + defaultAddress: string | undefined, + config: Pick | undefined, +): string { + const managed = config?.hostSettings?.managedEmail; + if ( + managed?.enabled && + (!managed.sendingDomain || sender?.split('@')[1] !== managed.sendingDomain) + ) { + return defaultAddress || ''; + } + return sender || defaultAddress || ''; +} + +/** The document is additionally sandboxed; links in a historical preview are inert. */ +export function activityPreviewDocument(html: string): string { + const document = new DOMParser().parseFromString(html, 'text/html'); + document.querySelectorAll('a, area').forEach((link) => { + link.removeAttribute('href'); + link.removeAttribute('target'); + }); + document.querySelectorAll('base, meta[http-equiv], form').forEach((element) => element.remove()); + const style = document.createElement('style'); + style.textContent = 'html { scrollbar-width: none; } html::-webkit-scrollbar { display: none; }'; + document.head.append(style); + return `${document.documentElement.outerHTML}`; +} diff --git a/apps/admin/src/members/activity/activity-email-preview.test.tsx b/apps/admin/src/members/activity/activity-email-preview.test.tsx new file mode 100644 index 00000000000..882319ac7c6 --- /dev/null +++ b/apps/admin/src/members/activity/activity-email-preview.test.tsx @@ -0,0 +1,175 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import ActivityEmailPreview from './activity-email-preview'; +import { + activityEmailPreviewData, + activityPreviewDocument, + activitySenderAddress, +} from './activity-email-preview-data'; + +const queries = vi.hoisted(() => ({ + preview: vi.fn(), + newsletters: vi.fn(), + config: vi.fn(), + settings: vi.fn(), + retry: vi.fn(), +})); + +vi.mock('@tryghost/admin-x-framework/api/email-previews', () => ({ + useEmailPreview: queries.preview, +})); +vi.mock('@tryghost/admin-x-framework/api/newsletters', () => ({ + useBrowseNewsletters: queries.newsletters, +})); +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ useBrowseConfig: queries.config })); +vi.mock('@tryghost/admin-x-framework/api/settings', async () => ({ + ...(await vi.importActual( + '@tryghost/admin-x-framework/api/settings', + )), + useBrowseSettings: queries.settings, +})); + +const savedEmail = { + html: '

Saved body

A link', + subject: 'Original subject', + post_id: 'post-1', +}; + +beforeEach(() => { + vi.clearAllMocks(); + queries.preview.mockReturnValue({ isLoading: false, isError: false, refetch: queries.retry }); + queries.newsletters.mockReturnValue({ + data: { newsletters: [{ sender_name: 'Newsletter', sender_email: 'news@example.com' }] }, + }); + queries.config.mockReturnValue({ data: { config: {} } }); + queries.settings.mockReturnValue({ + data: { + settings: [ + { key: 'title', value: 'Publication' }, + { key: 'default_email_address', value: 'default@example.com' }, + ], + }, + }); +}); + +describe('Activity email preview', () => { + it('uses stored HTML and subject without generating a new preview, and disables navigation', () => { + render(); + expect(screen.getByRole('dialog', { name: 'Email preview' })).toBeTruthy(); + expect(screen.getByText('Original subject', { exact: false })).toBeTruthy(); + expect(screen.getByText('Newsletter ', { exact: false })).toBeTruthy(); + expect(screen.getByText('Jamie Larson ', { exact: false })).toBeTruthy(); + const frame = screen.getByTitle('Email content'); + expect(frame.getAttribute('sandbox')).toBe(''); + expect(frame.getAttribute('srcdoc')).toContain('Saved body'); + expect(frame.getAttribute('srcdoc')).not.toContain('href='); + expect(queries.preview).toHaveBeenCalledWith('post-1', { enabled: false }); + fireEvent.click(screen.getByRole('radio', { name: 'Mobile' })); + expect( + screen.getByTitle('Email content').closest('[data-device]')?.getAttribute('data-device'), + ).toBe('mobile'); + }); + + it('supports keyboard dismissal and the close button', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('returns focus to the activity link after closing', async () => { + function ActivityLink() { + const [open, setOpen] = useState(false); + return ( + <> + + {open && setOpen(false)} />} + + ); + } + render(); + const opener = screen.getByRole('button', { name: 'Preview saved email' }); + opener.focus(); + fireEvent.click(opener); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(window.document.activeElement).toBe(opener)); + }); + + it('loads a missing historical body using the post id and offers retry after a failed request', () => { + queries.preview.mockReturnValue({ isLoading: true }); + const { rerender } = render( + , + ); + expect(queries.preview).toHaveBeenCalledWith('post-1', { enabled: true }); + expect(screen.queryByTitle('Email content')).toBeNull(); + queries.preview.mockReturnValue({ isLoading: false, isError: true, refetch: queries.retry }); + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(queries.retry).toHaveBeenCalledOnce(); + queries.preview.mockReturnValue({ + data: { email_previews: [{ html: '

Fallback

', subject: 'Rendered subject' }] }, + }); + rerender(); + expect(screen.getByTitle('Email content').getAttribute('srcdoc')).toContain('Fallback'); + expect(screen.getByText('Rendered subject', { exact: false })).toBeTruthy(); + }); + + it('does not interpret a deleted email or automated email id as a post id', () => { + render( + , + ); + expect(queries.preview).toHaveBeenCalledWith('', { enabled: false }); + expect(screen.getByText('The original email content is no longer available.')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull(); + }); + + it('keeps the historical body visible and allows retry when sender details fail', () => { + queries.settings.mockReturnValue({ isError: true, refetch: queries.retry }); + queries.config.mockReturnValue({ refetch: queries.retry }); + queries.newsletters.mockReturnValue({ refetch: queries.retry }); + render(); + expect(screen.getByTitle('Email content').getAttribute('srcdoc')).toContain('Saved body'); + expect(screen.getByRole('alert').textContent).toBe('Couldn’t load sender details.'); + fireEvent.click(screen.getByRole('button', { name: 'Retry sender details' })); + expect(queries.retry).toHaveBeenCalledTimes(3); + }); +}); + +describe('Activity email data', () => { + it('accepts nested stored emails with an empty subject and does not require a style tag or doctype', () => { + expect( + activityEmailPreviewData({ email: { html: '

Original

', subject: '' } }).stored, + ).toEqual({ html: '

Original

', subject: '' }); + expect(activityEmailPreviewData({ id: 'post-2', title: 'A post' }).postId).toBe('post-2'); + expect(activityPreviewDocument('

Original

')).toContain('

Original

'); + }); + + it('neutralizes refresh redirects, bases, form submission and image-map links', () => { + const html = activityPreviewDocument( + '
', + ); + expect(html).not.toMatch(/ { + expect( + activitySenderAddress('other@example.com', 'default@managed.com', { + hostSettings: { managedEmail: { enabled: true } }, + }), + ).toBe('default@managed.com'); + expect( + activitySenderAddress('other@example.com', 'default@managed.com', { + hostSettings: { managedEmail: { enabled: true, sendingDomain: 'managed.com' } }, + }), + ).toBe('default@managed.com'); + expect( + activitySenderAddress('news@managed.com', 'default@managed.com', { + hostSettings: { managedEmail: { enabled: true, sendingDomain: 'managed.com' } }, + }), + ).toBe('news@managed.com'); + }); +}); diff --git a/apps/admin/src/members/activity/activity-email-preview.tsx b/apps/admin/src/members/activity/activity-email-preview.tsx new file mode 100644 index 00000000000..1e12833219c --- /dev/null +++ b/apps/admin/src/members/activity/activity-email-preview.tsx @@ -0,0 +1,214 @@ +import { useMemo, useState } from 'react'; +import { + Button, + Dialog, + DialogContent, + DialogTitle, + EmptyIndicator, + LoadingIndicator, + PreviewChrome, + ToggleGroup, + ToggleGroupItem, +} from '@tryghost/shade/components'; +import { Grid, Inline, Stack } from '@tryghost/shade/primitives'; +import { LucideIcon, cn } from '@tryghost/shade/utils'; +import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; +import { useEmailPreview } from '@tryghost/admin-x-framework/api/email-previews'; +import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters'; +import { getSettingValues, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { + activityEmailPreviewData, + activityPreviewDocument, + activitySenderAddress, +} from './activity-email-preview-data'; + +interface ActivityEmailPreviewProps { + /** The event's email object, a nested email, or a post with its identity. */ + email: unknown; + onClose: () => void; +} + +export default function ActivityEmailPreview({ email, onClose }: ActivityEmailPreviewProps) { + const [device, setDevice] = useState<'desktop' | 'mobile'>('desktop'); + const [opener] = useState(() => window.document.activeElement); + const source = activityEmailPreviewData(email); + const fallbackEnabled = !source.stored && !!source.postId; + const previewQuery = useEmailPreview(source.postId ?? '', { enabled: fallbackEnabled }); + const newslettersQuery = useBrowseNewsletters({ + enabled: !source.newsletter, + searchParams: { filter: 'status:active', limit: '1' }, + }); + const settingsQuery = useBrowseSettings(); + const configQuery = useBrowseConfig(); + const [siteTitle, defaultEmailAddress] = getSettingValues( + settingsQuery.data?.settings ?? [], + ['title', 'default_email_address'], + ); + const newsletter = source.newsletter ?? newslettersQuery.data?.newsletters[0]; + const senderName = newsletter?.sender_name || siteTitle; + const senderAddress = activitySenderAddress( + newsletter?.sender_email ?? undefined, + defaultEmailAddress, + configQuery.data?.config, + ); + const preview = + source.stored ?? + (fallbackEnabled && !previewQuery.isError ? previewQuery.data?.email_previews[0] : undefined); + const document = useMemo( + () => (preview ? activityPreviewDocument(preview.html) : undefined), + [preview?.html], + ); + const metadataError = + settingsQuery.isError || + configQuery.isError || + (!source.newsletter && newslettersQuery.isError); + const metadataLoading = + settingsQuery.isLoading || + configQuery.isLoading || + (!source.newsletter && newslettersQuery.isLoading); + const loading = fallbackEnabled && previewQuery.isLoading; + + return ( + { + if (!open) { + onClose(); + } + }} + > + { + if (opener instanceof HTMLElement && opener.isConnected) { + event.preventDefault(); + opener.focus(); + } + }} + onInteractOutside={(event) => event.preventDefault()} + > + + Email preview + { + if (value === 'desktop' || value === 'mobile') { + setDevice(value); + } + }} + > + + + + + + + + + + + div]:rounded-2xl [&>div]:shadow-2xl' + : 'shadow-2xl', + )} + data-device={device} + device={device} + > + + +

{preview?.subject}

+

+ From: + {metadataLoading ? ( + 'Loading sender…' + ) : metadataError ? ( + 'Sender unavailable' + ) : ( + <> + {senderName} + {senderAddress && ` <${senderAddress}>`} + + )} +

+

+ To: Jamie Larson + <jamie@example.com> +

+ {metadataError && ( + +

Couldn’t load sender details.

+ +
+ )} +
+ {loading ? ( + + + + ) : preview ? ( +