From bb1cc72633d93576b947338f5012b12117869aed Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Thu, 1 May 2025 17:19:46 -0700 Subject: [PATCH 01/16] Added updated attribution endpoint and hooked it up to Post Analytics Growth tab --- apps/admin-x-framework/src/api/stats.ts | 21 +++++- apps/posts/src/hooks/usePostReferrers.ts | 39 +++++++++++ apps/posts/src/views/PostAnalytics/Growth.tsx | 37 ++++------ ghost/core/core/server/api/endpoints/stats.js | 24 +++++++ .../services/stats/ReferrersStatsService.js | 70 +++++++++++++++++++ .../server/services/stats/StatsService.js | 10 +++ .../server/web/api/endpoints/admin/routes.js | 1 + 7 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 apps/posts/src/hooks/usePostReferrers.ts diff --git a/apps/admin-x-framework/src/api/stats.ts b/apps/admin-x-framework/src/api/stats.ts index 30a1f76a741..8b6619e400e 100644 --- a/apps/admin-x-framework/src/api/stats.ts +++ b/apps/admin-x-framework/src/api/stats.ts @@ -1,4 +1,4 @@ -import {Meta, createQuery} from '../utils/api/hooks'; +import {Meta, createQuery, createQueryWithId} from '../utils/api/hooks'; // Types @@ -48,12 +48,24 @@ export type TopPostsStatsResponseType = { meta: Meta; }; +export type PostReferrerStatItem = { + source: string; + free_members: number; + paid_members: number; + mrr: number; +}; + +export type PostReferrersResponseType = { + stats: PostReferrerStatItem[]; + meta: Meta; +}; + // Requests const dataType = 'TopContentResponseType'; const memberCountHistoryDataType = 'MemberCountHistoryResponseType'; const topPostsStatsDataType = 'TopPostsStatsResponseType'; - +const postReferrersDataType = 'PostReferrersResponseType'; export const useTopContent = createQuery({ dataType, path: '/stats/top-content/' @@ -68,3 +80,8 @@ export const useTopPostsStats = createQuery({ dataType: topPostsStatsDataType, path: '/stats/top-posts/' }); + +export const usePostReferrers = createQueryWithId({ + dataType: postReferrersDataType, + path: id => `/stats/referrers/posts/${id}/alpha` +}); diff --git a/apps/posts/src/hooks/usePostReferrers.ts b/apps/posts/src/hooks/usePostReferrers.ts new file mode 100644 index 00000000000..490d464c352 --- /dev/null +++ b/apps/posts/src/hooks/usePostReferrers.ts @@ -0,0 +1,39 @@ +import {PostReferrerStatItem, usePostReferrers as usePostReferrersAPI} from '@tryghost/admin-x-framework/api/stats'; +import {useMemo} from 'react'; + +// Calculate totals from referrer data +const calculateTotals = (referrerData: PostReferrerStatItem[]) => { + if (!referrerData.length) { + return { + free_members: 0, + paid_members: 0, + mrr: 0 + }; + } + + const totals = referrerData.reduce((acc, item) => { + acc.free_members += item.free_members || 0; + acc.paid_members += item.paid_members || 0; + acc.mrr += item.mrr || 0; + return acc; + }, {free_members: 0, paid_members: 0, mrr: 0}); + + return totals; +}; + +export const usePostReferrers = (postId: string) => { + // Fetch post referrer data from API + const {data: postReferrerResponse, isLoading} = usePostReferrersAPI(postId); + + // Extract the stats data + const stats = useMemo(() => postReferrerResponse?.stats || [], [postReferrerResponse]); + + // Calculate totals + const totals = useMemo(() => calculateTotals(stats), [stats]); + + return { + isLoading, + stats, + totals + }; +}; diff --git a/apps/posts/src/views/PostAnalytics/Growth.tsx b/apps/posts/src/views/PostAnalytics/Growth.tsx index 47817eae39c..8313b941fd2 100644 --- a/apps/posts/src/views/PostAnalytics/Growth.tsx +++ b/apps/posts/src/views/PostAnalytics/Growth.tsx @@ -4,25 +4,16 @@ import PostAnalyticsContent from './components/PostAnalyticsContent'; import PostAnalyticsHeader from './components/PostAnalyticsHeader'; import PostAnalyticsLayout from './layout/PostAnalyticsLayout'; import {Card, CardContent, CardDescription, CardHeader, CardTitle, LucideIcon, Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, ViewHeader, ViewHeaderActions, formatNumber} from '@tryghost/shade'; - +import {usePostReferrers} from '../../hooks/usePostReferrers'; const STATS_DEFAULT_SOURCE_ICON_URL = 'https://static.ghost.org/v5.0.0/images/globe-icon.svg'; interface postAnalyticsProps {} const Growth: React.FC = () => { // const {isLoading: isConfigLoading} = useGlobalData(); - // const {range} = useGlobalData(); - const isLoading = false; - - const mockTopSources = [ - {id: 'source-001', title: 'google.com', freeMembers: 17, paidMembers: 7, mrr: 8}, - {id: 'source-002', title: 'twitter.com', freeMembers: 12, paidMembers: 5, mrr: 6}, - {id: 'source-003', title: 'facebook.com', freeMembers: 9, paidMembers: 4, mrr: 5}, - {id: 'source-004', title: 'linkedin.com', freeMembers: 8, paidMembers: 3, mrr: 4}, - {id: 'source-005', title: 'reddit.com', freeMembers: 7, paidMembers: 2, mrr: 3}, - {id: 'source-006', title: 'medium.com', freeMembers: 6, paidMembers: 2, mrr: 3} - ]; + const {stats: postReferrers, isLoading, totals} = usePostReferrers('000000006314fec72721973c'); + // const {range} = useGlobalData(); return ( @@ -41,21 +32,21 @@ const Growth: React.FC = () => { Free members - {formatNumber(22)} + {formatNumber(totals.free_members)} Paid members - {formatNumber(8)} + {formatNumber(totals.paid_members)} MRR - +$180 + +${formatNumber(totals.mrr)} @@ -75,22 +66,22 @@ const Growth: React.FC = () => { - {mockTopSources.map(source => ( - + {postReferrers?.map(row => ( + - + ) => { e.currentTarget.src = STATS_DEFAULT_SOURCE_ICON_URL; }} /> - {source.title || 'Direct'} + {row.source || 'Direct'} - +{formatNumber(source.freeMembers)} - +{formatNumber(source.paidMembers)} - +${source.mrr} + +{formatNumber(row.free_members)} + +{formatNumber(row.paid_members)} + +${formatNumber(row.mrr)} ))} diff --git a/ghost/core/core/server/api/endpoints/stats.js b/ghost/core/core/server/api/endpoints/stats.js index baf70fa615b..b4c1b615059 100644 --- a/ghost/core/core/server/api/endpoints/stats.js +++ b/ghost/core/core/server/api/endpoints/stats.js @@ -89,6 +89,30 @@ const controller = { return await statsService.api.getPostReferrers(frame.data.id); } }, + postReferrersAlpha: { + headers: { + cacheInvalidate: false + }, + data: [ + 'id' + ], + permissions: { + docName: 'posts', + method: 'browse' + }, + cache: statsService.cache, + generateCacheKeyData(frame) { + return { + method: 'postReferrersAlpha', + data: { + id: frame.data.id + } + }; + }, + async query(frame) { + return await statsService.api.getPostReferrersAlpha(frame.data.id); + } + }, referrersHistory: { headers: { cacheInvalidate: false diff --git a/ghost/core/core/server/services/stats/ReferrersStatsService.js b/ghost/core/core/server/services/stats/ReferrersStatsService.js index aebb06b667b..08c55f5aefc 100644 --- a/ghost/core/core/server/services/stats/ReferrersStatsService.js +++ b/ghost/core/core/server/services/stats/ReferrersStatsService.js @@ -54,6 +54,76 @@ class ReferrersStatsService { return [...map.values()].sort((a, b) => b.paid_conversions - a.paid_conversions); } + async getForPostAlpha(postId) { + const knex = this.knex; + const freeMembers = await knex('members_created_events as mce') + .select('mce.referrer_source as source') + .countDistinct('mce.member_id as free_members') + .leftJoin('members_subscription_created_events as msce', function () { + this.on('mce.member_id', '=', 'msce.member_id') + .andOn('mce.attribution_id', '=', 'msce.attribution_id') + .andOnVal('msce.attribution_type', '=', 'post'); + }) + .where('mce.attribution_id', postId) + .where('mce.attribution_type', 'post') + .whereNull('msce.id') + .groupBy('mce.referrer_source'); + + const paidMembers = await knex('members_subscription_created_events as msce') + .select('msce.referrer_source as source') + .countDistinct('msce.member_id as paid_members') + .where('msce.attribution_id', postId) + .where('msce.attribution_type', 'post') + .groupBy('msce.referrer_source'); + + const mrr = await knex('members_subscription_created_events as msce') + .select('msce.referrer_source as source') + .sum('mpse.mrr_delta as mrr') + .join('members_paid_subscription_events as mpse', function () { + this.on('mpse.subscription_id', '=', 'msce.subscription_id') + .andOn('mpse.member_id', '=', 'msce.member_id'); + }) + .where('msce.attribution_id', postId) + .where('msce.attribution_type', 'post') + .groupBy('msce.referrer_source'); + + const map = new Map(); + for (const row of freeMembers) { + map.set(row.source, { + source: row.source, + free_members: row.free_members, + paid_members: 0, + mrr: 0 + }); + } + + for (const row of paidMembers) { + const existing = map.get(row.source) ?? { + source: row.source, + free_members: 0, + paid_members: 0, + mrr: 0 + }; + existing.paid_members = row.paid_members; + map.set(row.source, existing); + } + + for (const row of mrr) { + const existing = map.get(row.source) ?? { + source: row.source, + free_members: 0, + paid_members: 0, + mrr: 0 + }; + existing.mrr = row.mrr; + map.set(row.source, existing); + } + + const result = [...map.values()].sort((a, b) => b.mrr - a.mrr); + + return result; + } + /** * Return a list of all the attribution sources, with their signup and conversion counts on each date * @returns {Promise<{data: AttributionCountStat[], meta: {}}>} diff --git a/ghost/core/core/server/services/stats/StatsService.js b/ghost/core/core/server/services/stats/StatsService.js index 61f6dc8a696..8b6156323c5 100644 --- a/ghost/core/core/server/services/stats/StatsService.js +++ b/ghost/core/core/server/services/stats/StatsService.js @@ -63,6 +63,16 @@ class StatsService { }; } + /** + * @param {string} postId + */ + async getPostReferrersAlpha(postId) { + return { + data: await this.referrers.getForPostAlpha(postId), + meta: {} + }; + } + /** * @param {Object} options */ diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 6bb795fe89a..7214f814602 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -153,6 +153,7 @@ module.exports = function apiRoutes() { router.get('/stats/mrr', mw.authAdminApi, http(api.stats.mrr)); router.get('/stats/subscriptions', mw.authAdminApi, http(api.stats.subscriptions)); router.get('/stats/referrers/posts/:id', mw.authAdminApi, http(api.stats.postReferrers)); + router.get('/stats/referrers/posts/:id/alpha', mw.authAdminApi, http(api.stats.postReferrersAlpha)); router.get('/stats/referrers', mw.authAdminApi, http(api.stats.referrersHistory)); router.get('/stats/top-content', mw.authAdminApi, http(api.stats.topContent)); router.get('/stats/top-posts', mw.authAdminApi, http(api.stats.topPosts)); From 7b0b5b5defe4539f6aa36ac0c7a8497bfae349d3 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 12:07:52 -0700 Subject: [PATCH 02/16] Moved new endpoint into PostsStatsService and updated endpoint --- ghost/core/core/server/api/endpoints/stats.js | 50 ++++++------ .../services/stats/PostsStatsService.js | 76 +++++++++++++++++++ .../services/stats/ReferrersStatsService.js | 70 ----------------- .../server/services/stats/StatsService.js | 4 +- .../server/web/api/endpoints/admin/routes.js | 9 ++- .../unit/server/services/stats/posts.test.js | 10 +++ 6 files changed, 119 insertions(+), 100 deletions(-) diff --git a/ghost/core/core/server/api/endpoints/stats.js b/ghost/core/core/server/api/endpoints/stats.js index b4c1b615059..55830ee10a7 100644 --- a/ghost/core/core/server/api/endpoints/stats.js +++ b/ghost/core/core/server/api/endpoints/stats.js @@ -89,30 +89,6 @@ const controller = { return await statsService.api.getPostReferrers(frame.data.id); } }, - postReferrersAlpha: { - headers: { - cacheInvalidate: false - }, - data: [ - 'id' - ], - permissions: { - docName: 'posts', - method: 'browse' - }, - cache: statsService.cache, - generateCacheKeyData(frame) { - return { - method: 'postReferrersAlpha', - data: { - id: frame.data.id - } - }; - }, - async query(frame) { - return await statsService.api.getPostReferrersAlpha(frame.data.id); - } - }, referrersHistory: { headers: { cacheInvalidate: false @@ -191,7 +167,31 @@ const controller = { async query(frame) { return await statsService.api.getTopPosts(frame.options); } - } + }, + postReferrersAlpha: { + headers: { + cacheInvalidate: false + }, + data: [ + 'id' + ], + permissions: { + docName: 'posts', + method: 'browse' + }, + cache: statsService.cache, + generateCacheKeyData(frame) { + return { + method: 'postReferrersAlpha', + data: { + id: frame.data.id + } + }; + }, + async query(frame) { + return await statsService.api.getPostReferrersAlpha(frame.data.id, frame.options); + } + }, }; module.exports = controller; diff --git a/ghost/core/core/server/services/stats/PostsStatsService.js b/ghost/core/core/server/services/stats/PostsStatsService.js index 5276aae8e0a..e1a287820d0 100644 --- a/ghost/core/core/server/services/stats/PostsStatsService.js +++ b/ghost/core/core/server/services/stats/PostsStatsService.js @@ -86,6 +86,82 @@ class PostsStatsService { } } + /** + * Get referrers for a post + * @param {string} postId + * @param {TopPostsOptions} options + * @returns {Promise<{data: TopPostResult[]}>} The referrers for the post + */ + async getForPostAlpha(postId, options) { + const knex = this.knex; + const freeMembers = await knex('members_created_events as mce') + .select('mce.referrer_source as source') + .countDistinct('mce.member_id as free_members') + .leftJoin('members_subscription_created_events as msce', function () { + this.on('mce.member_id', '=', 'msce.member_id') + .andOn('mce.attribution_id', '=', 'msce.attribution_id') + .andOnVal('msce.attribution_type', '=', 'post'); + }) + .where('mce.attribution_id', postId) + .where('mce.attribution_type', 'post') + .whereNull('msce.id') + .groupBy('mce.referrer_source'); + + const paidMembers = await knex('members_subscription_created_events as msce') + .select('msce.referrer_source as source') + .countDistinct('msce.member_id as paid_members') + .where('msce.attribution_id', postId) + .where('msce.attribution_type', 'post') + .groupBy('msce.referrer_source'); + + const mrr = await knex('members_subscription_created_events as msce') + .select('msce.referrer_source as source') + .sum('mpse.mrr_delta as mrr') + .join('members_paid_subscription_events as mpse', function () { + this.on('mpse.subscription_id', '=', 'msce.subscription_id') + .andOn('mpse.member_id', '=', 'msce.member_id'); + }) + .where('msce.attribution_id', postId) + .where('msce.attribution_type', 'post') + .groupBy('msce.referrer_source'); + + const map = new Map(); + for (const row of freeMembers) { + map.set(row.source, { + source: row.source, + free_members: row.free_members, + paid_members: 0, + mrr: 0 + }); + } + + for (const row of paidMembers) { + const existing = map.get(row.source) ?? { + source: row.source, + free_members: 0, + paid_members: 0, + mrr: 0 + }; + existing.paid_members = row.paid_members; + map.set(row.source, existing); + } + + for (const row of mrr) { + const existing = map.get(row.source) ?? { + source: row.source, + free_members: 0, + paid_members: 0, + mrr: 0 + }; + existing.mrr = row.mrr; + map.set(row.source, existing); + } + + const results = [...map.values()].sort((a, b) => b.mrr - a.mrr); + + return {data: results}; + } + /** * Build a subquery/CTE for free_members count * (Signed up on Post, Paid Elsewhere/Never) diff --git a/ghost/core/core/server/services/stats/ReferrersStatsService.js b/ghost/core/core/server/services/stats/ReferrersStatsService.js index 08c55f5aefc..aebb06b667b 100644 --- a/ghost/core/core/server/services/stats/ReferrersStatsService.js +++ b/ghost/core/core/server/services/stats/ReferrersStatsService.js @@ -54,76 +54,6 @@ class ReferrersStatsService { return [...map.values()].sort((a, b) => b.paid_conversions - a.paid_conversions); } - async getForPostAlpha(postId) { - const knex = this.knex; - const freeMembers = await knex('members_created_events as mce') - .select('mce.referrer_source as source') - .countDistinct('mce.member_id as free_members') - .leftJoin('members_subscription_created_events as msce', function () { - this.on('mce.member_id', '=', 'msce.member_id') - .andOn('mce.attribution_id', '=', 'msce.attribution_id') - .andOnVal('msce.attribution_type', '=', 'post'); - }) - .where('mce.attribution_id', postId) - .where('mce.attribution_type', 'post') - .whereNull('msce.id') - .groupBy('mce.referrer_source'); - - const paidMembers = await knex('members_subscription_created_events as msce') - .select('msce.referrer_source as source') - .countDistinct('msce.member_id as paid_members') - .where('msce.attribution_id', postId) - .where('msce.attribution_type', 'post') - .groupBy('msce.referrer_source'); - - const mrr = await knex('members_subscription_created_events as msce') - .select('msce.referrer_source as source') - .sum('mpse.mrr_delta as mrr') - .join('members_paid_subscription_events as mpse', function () { - this.on('mpse.subscription_id', '=', 'msce.subscription_id') - .andOn('mpse.member_id', '=', 'msce.member_id'); - }) - .where('msce.attribution_id', postId) - .where('msce.attribution_type', 'post') - .groupBy('msce.referrer_source'); - - const map = new Map(); - for (const row of freeMembers) { - map.set(row.source, { - source: row.source, - free_members: row.free_members, - paid_members: 0, - mrr: 0 - }); - } - - for (const row of paidMembers) { - const existing = map.get(row.source) ?? { - source: row.source, - free_members: 0, - paid_members: 0, - mrr: 0 - }; - existing.paid_members = row.paid_members; - map.set(row.source, existing); - } - - for (const row of mrr) { - const existing = map.get(row.source) ?? { - source: row.source, - free_members: 0, - paid_members: 0, - mrr: 0 - }; - existing.mrr = row.mrr; - map.set(row.source, existing); - } - - const result = [...map.values()].sort((a, b) => b.mrr - a.mrr); - - return result; - } - /** * Return a list of all the attribution sources, with their signup and conversion counts on each date * @returns {Promise<{data: AttributionCountStat[], meta: {}}>} diff --git a/ghost/core/core/server/services/stats/StatsService.js b/ghost/core/core/server/services/stats/StatsService.js index 8b6156323c5..73d47d9e681 100644 --- a/ghost/core/core/server/services/stats/StatsService.js +++ b/ghost/core/core/server/services/stats/StatsService.js @@ -66,9 +66,9 @@ class StatsService { /** * @param {string} postId */ - async getPostReferrersAlpha(postId) { + async getPostReferrersAlpha(postId, options) { return { - data: await this.referrers.getForPostAlpha(postId), + data: await this.posts.getForPostAlpha(postId, options), meta: {} }; } diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 7214f814602..00f1baee85f 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -3,6 +3,7 @@ const api = require('../../../../api').endpoints; const {http} = require('@tryghost/api-framework'); const apiMw = require('../../middleware'); const mw = require('./middleware'); +const labs = require('../../../../../shared/labs'); const shared = require('../../../shared'); @@ -153,10 +154,12 @@ module.exports = function apiRoutes() { router.get('/stats/mrr', mw.authAdminApi, http(api.stats.mrr)); router.get('/stats/subscriptions', mw.authAdminApi, http(api.stats.subscriptions)); router.get('/stats/referrers/posts/:id', mw.authAdminApi, http(api.stats.postReferrers)); - router.get('/stats/referrers/posts/:id/alpha', mw.authAdminApi, http(api.stats.postReferrersAlpha)); router.get('/stats/referrers', mw.authAdminApi, http(api.stats.referrersHistory)); - router.get('/stats/top-content', mw.authAdminApi, http(api.stats.topContent)); - router.get('/stats/top-posts', mw.authAdminApi, http(api.stats.topPosts)); + if (labs.isSet('trafficAnalytics')) { + router.get('/stats/top-posts', mw.authAdminApi, http(api.stats.topPosts)); + router.get('/stats/top-content', mw.authAdminApi, http(api.stats.topContent)); + router.get('/stats/referrers/posts/:id/alpha', mw.authAdminApi, http(api.stats.postReferrersAlpha)); + } // ## Labels router.get('/labels', mw.authAdminApi, http(api.labels.browse)); diff --git a/ghost/core/test/unit/server/services/stats/posts.test.js b/ghost/core/test/unit/server/services/stats/posts.test.js index fb3ccdb9575..bcf9878e22b 100644 --- a/ghost/core/test/unit/server/services/stats/posts.test.js +++ b/ghost/core/test/unit/server/services/stats/posts.test.js @@ -122,6 +122,7 @@ describe('PostsStatsService', function () { table.string('attribution_id').index(); table.string('attribution_type'); table.dateTime('created_at'); + table.string('referrer_source'); }); await db.schema.createTable('members_subscription_created_events', function (table) { @@ -131,6 +132,7 @@ describe('PostsStatsService', function () { table.string('attribution_id').index(); table.string('attribution_type'); table.dateTime('created_at'); + table.string('referrer_source'); }); await db.schema.createTable('members_paid_subscription_events', function (table) { @@ -320,4 +322,12 @@ describe('PostsStatsService', function () { assert.equal(result.data[1].post_id, 'post2'); }); }); + + describe('getForPostAlpha', function () { + it('returns empty array when no events exist', async function () { + const result = await service.getForPostAlpha('post1'); + assert.ok(result.data, 'Result should have a data property'); + assert.equal(result.data.length, 0, 'Should return empty array when no events exist'); + }); + }); }); From 672fee032521a51698b07027a7347e3cb708abda Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 12:14:00 -0700 Subject: [PATCH 03/16] Fixed endpoint response fields --- ghost/core/core/server/services/stats/StatsService.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ghost/core/core/server/services/stats/StatsService.js b/ghost/core/core/server/services/stats/StatsService.js index 73d47d9e681..873a093094e 100644 --- a/ghost/core/core/server/services/stats/StatsService.js +++ b/ghost/core/core/server/services/stats/StatsService.js @@ -67,10 +67,8 @@ class StatsService { * @param {string} postId */ async getPostReferrersAlpha(postId, options) { - return { - data: await this.posts.getForPostAlpha(postId, options), - meta: {} - }; + const result = await this.posts.getForPostAlpha(postId, options); + return result; } /** From 615031508b7f36403f3fdc033a0b25548d71c3f0 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 15:37:14 -0700 Subject: [PATCH 04/16] Renamed the method and added referrer_source to tests --- ghost/core/core/server/api/endpoints/stats.js | 11 ++- .../services/stats/PostsStatsService.js | 2 +- .../server/services/stats/StatsService.js | 4 +- .../unit/server/services/stats/posts.test.js | 72 ++++++++++++------- 4 files changed, 58 insertions(+), 31 deletions(-) diff --git a/ghost/core/core/server/api/endpoints/stats.js b/ghost/core/core/server/api/endpoints/stats.js index 55830ee10a7..3a5d47407be 100644 --- a/ghost/core/core/server/api/endpoints/stats.js +++ b/ghost/core/core/server/api/endpoints/stats.js @@ -172,6 +172,13 @@ const controller = { headers: { cacheInvalidate: false }, + options: [ + 'order', + 'limit', + 'date_from', + 'date_to', + 'timezone' + ], data: [ 'id' ], @@ -182,14 +189,14 @@ const controller = { cache: statsService.cache, generateCacheKeyData(frame) { return { - method: 'postReferrersAlpha', + method: 'getReferrersForPost', data: { id: frame.data.id } }; }, async query(frame) { - return await statsService.api.getPostReferrersAlpha(frame.data.id, frame.options); + return await statsService.api.getReferrersForPost(frame.data.id, frame.options); } }, }; diff --git a/ghost/core/core/server/services/stats/PostsStatsService.js b/ghost/core/core/server/services/stats/PostsStatsService.js index e1a287820d0..4a704157f91 100644 --- a/ghost/core/core/server/services/stats/PostsStatsService.js +++ b/ghost/core/core/server/services/stats/PostsStatsService.js @@ -92,7 +92,7 @@ class PostsStatsService { * @param {TopPostsOptions} options * @returns {Promise<{data: TopPostResult[]}>} The referrers for the post */ - async getForPostAlpha(postId, options) { + async getReferrersForPost(postId, options) { const knex = this.knex; const freeMembers = await knex('members_created_events as mce') .select('mce.referrer_source as source') diff --git a/ghost/core/core/server/services/stats/StatsService.js b/ghost/core/core/server/services/stats/StatsService.js index 873a093094e..1c269d99be9 100644 --- a/ghost/core/core/server/services/stats/StatsService.js +++ b/ghost/core/core/server/services/stats/StatsService.js @@ -66,8 +66,8 @@ class StatsService { /** * @param {string} postId */ - async getPostReferrersAlpha(postId, options) { - const result = await this.posts.getForPostAlpha(postId, options); + async getReferrersForPost(postId, options) { + const result = await this.posts.getReferrersForPost(postId, options); return result; } diff --git a/ghost/core/test/unit/server/services/stats/posts.test.js b/ghost/core/test/unit/server/services/stats/posts.test.js index bcf9878e22b..d7215315fa5 100644 --- a/ghost/core/test/unit/server/services/stats/posts.test.js +++ b/ghost/core/test/unit/server/services/stats/posts.test.js @@ -43,7 +43,7 @@ describe('PostsStatsService', function () { await db('posts').insert({id, title}); } - async function _createFreeSignupEvent(postId, memberId, createdAt = new Date()) { + async function _createFreeSignupEvent(postId, memberId, referrerSource, createdAt = new Date()) { eventIdCounter += 1; const eventId = `free_event_${eventIdCounter}`; await db('members_created_events').insert({ @@ -51,11 +51,12 @@ describe('PostsStatsService', function () { member_id: memberId, attribution_id: postId, attribution_type: 'post', + referrer_source: referrerSource, created_at: createdAt }); } - async function _createPaidConversionEvent(postId, memberId, subscriptionId, mrr, createdAt = new Date()) { + async function _createPaidConversionEvent(postId, memberId, subscriptionId, mrr, referrerSource, createdAt = new Date()) { eventIdCounter += 1; const subCreatedEventId = `sub_created_${eventIdCounter}`; const paidEventId = `paid_event_${eventIdCounter}`; @@ -66,6 +67,7 @@ describe('PostsStatsService', function () { subscription_id: subscriptionId, attribution_id: postId, attribution_type: 'post', + referrer_source: referrerSource, created_at: createdAt }); @@ -78,27 +80,27 @@ describe('PostsStatsService', function () { }); } - async function _createFreeSignup(postId, memberId = null) { + async function _createFreeSignup(postId, referrerSource,memberId = null) { memberIdCounter += 1; const finalMemberId = memberId || `member_${memberIdCounter}`; await _createFreeSignupEvent(postId, finalMemberId); } - async function _createPaidSignup(postId, mrr, memberId = null, subscriptionId = null) { + async function _createPaidSignup(postId, mrr, referrerSource, memberId = null, subscriptionId = null) { memberIdCounter += 1; const finalMemberId = memberId || `member_${memberIdCounter}`; subscriptionIdCounter += 1; const finalSubscriptionId = subscriptionId || `sub_${subscriptionIdCounter}`; - await _createFreeSignupEvent(postId, finalMemberId); + await _createFreeSignupEvent(postId, finalMemberId, referrerSource); await _createPaidConversionEvent(postId, finalMemberId, finalSubscriptionId, mrr); } - async function _createPaidConversion(signupPostId, conversionPostId, mrr, memberId = null, subscriptionId = null) { + async function _createPaidConversion(signupPostId, conversionPostId, mrr, referrerSource, memberId = null, subscriptionId = null) { memberIdCounter += 1; const finalMemberId = memberId || `member_${memberIdCounter}`; subscriptionIdCounter += 1; const finalSubscriptionId = subscriptionId || `sub_${subscriptionIdCounter}`; - await _createFreeSignupEvent(signupPostId, finalMemberId); + await _createFreeSignupEvent(signupPostId, finalMemberId, referrerSource); await _createPaidConversionEvent(conversionPostId, finalMemberId, finalSubscriptionId, mrr); } @@ -191,11 +193,11 @@ describe('PostsStatsService', function () { }); it('correctly ranks posts by free_members', async function () { - await _createFreeSignup('post1'); - await _createFreeSignup('post2'); - await _createPaidSignup('post1', 500); - await _createPaidSignup('post3', 1000); - await _createPaidConversion('post1', 'post2', 500); + await _createFreeSignup('post1', 'referrer_1'); + await _createFreeSignup('post2', 'referrer_2'); + await _createPaidSignup('post1', 500, 'referrer_1'); + await _createPaidSignup('post3', 1000, 'referrer_3'); + await _createPaidConversion('post1', 'post2', 500, 'referrer_1'); const result = await service.getTopPosts({order: 'free_members desc'}); @@ -220,10 +222,10 @@ describe('PostsStatsService', function () { }); it('correctly ranks posts by paid_members', async function () { - await _createFreeSignup('post3'); - await _createPaidSignup('post1', 600); - await _createPaidConversion('post1', 'post2', 500); - await _createPaidConversion('post4', 'post2', 700); + await _createFreeSignup('post3', 'referrer_3'); + await _createPaidSignup('post1', 600, 'referrer_1'); + await _createPaidConversion('post1', 'post2', 500, 'referrer_1'); + await _createPaidConversion('post4', 'post2', 700, 'referrer_4'); const result = await service.getTopPosts({order: 'paid_members desc'}); @@ -248,10 +250,10 @@ describe('PostsStatsService', function () { }); it('correctly ranks posts by mrr', async function () { - await _createFreeSignup('post3'); - await _createPaidSignup('post1', 600); - await _createPaidConversion('post1', 'post2', 500); - await _createPaidConversion('post4', 'post2', 700); + await _createFreeSignup('post3', 'referrer_3'); + await _createPaidSignup('post1', 600, 'referrer_1'); + await _createPaidConversion('post1', 'post2', 500, 'referrer_1'); + await _createPaidConversion('post4', 'post2', 700, 'referrer_4'); const result = await service.getTopPosts({order: 'mrr desc'}); @@ -282,8 +284,8 @@ describe('PostsStatsService', function () { const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); // Create events at different dates - await _createFreeSignupEvent('post1', 'member_past', tenDaysAgo); - await _createFreeSignupEvent('post2', 'member_future', thirtyDaysAgo); + await _createFreeSignupEvent('post1', 'member_past', 'referrer_past', tenDaysAgo); + await _createFreeSignupEvent('post2', 'member_future', 'referrer_future', thirtyDaysAgo); const lastFifteenDaysResult = await service.getTopPosts({ date_from: fifteenDaysAgo, @@ -304,10 +306,10 @@ describe('PostsStatsService', function () { }); it('respects the limit parameter', async function () { - await _createFreeSignup('post1'); - await _createFreeSignup('post1'); - await _createFreeSignup('post2'); - await _createFreeSignup('post3'); + await _createFreeSignup('post1', 'referrer_1'); + await _createFreeSignup('post1', 'referrer_2'); + await _createFreeSignup('post2', 'referrer_3'); + await _createFreeSignup('post3', 'referrer_4'); const result = await service.getTopPosts({ order: 'free_members desc', @@ -329,5 +331,23 @@ describe('PostsStatsService', function () { assert.ok(result.data, 'Result should have a data property'); assert.equal(result.data.length, 0, 'Should return empty array when no events exist'); }); + + it('returns referrers for a post', async function () { + await _createFreeSignupEvent('post1', 'member_1', 'referrer_1', new Date()); + await _createFreeSignupEvent('post1', 'member_2', 'referrer_2', new Date()); + await _createFreeSignupEvent('post1', 'member_3', 'referrer_3', new Date()); + + const result = await service.getForPostAlpha('post1'); + assert.ok(result.data, 'Result should have a data property'); + assert.equal(result.data.length, 3, 'Should return 3 referrers'); + }); + + it.skip('correctly ranks posts by paid_members', async function () {}); + + it.skip('correctly ranks posts by mrr', async function () {}); + + it.skip('properly filters by date range', async function () {}); + + it.skip('respects the limit parameter', async function () {}); }); }); From d71a6ca171959224c78083e6f8f17c1609f1a6b9 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 16:17:54 -0700 Subject: [PATCH 05/16] Fixed linter error --- ghost/core/core/server/api/endpoints/stats.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/core/core/server/api/endpoints/stats.js b/ghost/core/core/server/api/endpoints/stats.js index 3a5d47407be..b1def40dfbe 100644 --- a/ghost/core/core/server/api/endpoints/stats.js +++ b/ghost/core/core/server/api/endpoints/stats.js @@ -198,7 +198,7 @@ const controller = { async query(frame) { return await statsService.api.getReferrersForPost(frame.data.id, frame.options); } - }, + } }; module.exports = controller; From eaa4211c76db978853024b9d2783257ea2da3aeb Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 16:18:46 -0700 Subject: [PATCH 06/16] Added tests and implementation for getReferrersForPost method --- .../services/stats/PostsStatsService.js | 273 ++++++++++++------ .../unit/server/services/stats/posts.test.js | 107 ++++++- 2 files changed, 278 insertions(+), 102 deletions(-) diff --git a/ghost/core/core/server/services/stats/PostsStatsService.js b/ghost/core/core/server/services/stats/PostsStatsService.js index 4a704157f91..020c47e3672 100644 --- a/ghost/core/core/server/services/stats/PostsStatsService.js +++ b/ghost/core/core/server/services/stats/PostsStatsService.js @@ -2,9 +2,9 @@ const logging = require('@tryghost/logging'); const errors = require('@tryghost/errors'); /** - * @typedef {Object} TopPostsOptions + * @typedef {Object} StatsServiceOptions * @property {string} [order='free_members desc'] - field to order by (free_members, paid_members, or mrr) and direction - * @property {number} [limit=20] - maximum number of results to return + * @property {number|string} [limit=20] - maximum number of results to return * @property {string} [date_from] - optional start date filter (YYYY-MM-DD) * @property {string} [date_to] - optional end date filter (YYYY-MM-DD) * @property {string} [timezone='UTC'] - optional timezone for date interpretation @@ -19,6 +19,14 @@ const errors = require('@tryghost/errors'); * @property {number} mrr - Total MRR from paid conversions attributed to this post */ +/** + * @typedef {Object} ReferrerStatsResult + * @property {string} source - The referrer source (e.g., domain) + * @property {number} free_members - Count of members who signed up via this post/referrer but did not have a paid conversion attributed to the same post/referrer + * @property {number} paid_members - Count of members whose paid conversion event was attributed to this post/referrer + * @property {number} mrr - Total MRR from paid conversions attributed to this post/referrer + */ + class PostsStatsService { /** * @param {object} deps @@ -31,14 +39,13 @@ class PostsStatsService { /** * Get top posts by attribution metrics (free_members, paid_members, or mrr) * - * @param {TopPostsOptions} options + * @param {StatsServiceOptions} options * @returns {Promise<{data: TopPostResult[]}>} The top posts based on the requested attribution metric */ async getTopPosts(options = {}) { - logging.info('TopPostsStatsService.getTopPosts called with options:', options); try { const order = options.order || 'free_members desc'; - const limitRaw = Number.parseInt(options.limit, 10); + const limitRaw = Number.parseInt(String(options.limit ?? 20), 10); // Ensure options.limit is a string for parseInt const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 20; const [orderField, orderDirection = 'desc'] = order.split(' '); @@ -74,7 +81,6 @@ class PostsStatsService { .leftJoin('paid', 'p.id', 'paid.post_id') .leftJoin('mrr', 'p.id', 'mrr.post_id'); - // Apply final ordering and limiting (Removed the WHERE clause filtering for activity) const results = await query .orderBy(orderField, orderDirection) .limit(limit); @@ -87,142 +93,124 @@ class PostsStatsService { } /** - * Get referrers for a post + * Get referrers for a specific post by attribution metrics * @param {string} postId - * @param {TopPostsOptions} options - * @returns {Promise<{data: TopPostResult[]}>} The referrers for the post + * @param {StatsServiceOptions} options + * @returns {Promise<{data: ReferrerStatsResult[]}>} The referrers for the post, ranked by the specified metric */ - async getReferrersForPost(postId, options) { - const knex = this.knex; - const freeMembers = await knex('members_created_events as mce') - .select('mce.referrer_source as source') - .countDistinct('mce.member_id as free_members') - .leftJoin('members_subscription_created_events as msce', function () { - this.on('mce.member_id', '=', 'msce.member_id') - .andOn('mce.attribution_id', '=', 'msce.attribution_id') - .andOnVal('msce.attribution_type', '=', 'post'); - }) - .where('mce.attribution_id', postId) - .where('mce.attribution_type', 'post') - .whereNull('msce.id') - .groupBy('mce.referrer_source'); - - const paidMembers = await knex('members_subscription_created_events as msce') - .select('msce.referrer_source as source') - .countDistinct('msce.member_id as paid_members') - .where('msce.attribution_id', postId) - .where('msce.attribution_type', 'post') - .groupBy('msce.referrer_source'); + async getReferrersForPost(postId, options = {}) { + try { + const order = options.order || 'free_members desc'; + const limitRaw = Number.parseInt(String(options.limit ?? 20), 10); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 20; + const [orderField, orderDirection = 'desc'] = order.split(' '); - const mrr = await knex('members_subscription_created_events as msce') - .select('msce.referrer_source as source') - .sum('mpse.mrr_delta as mrr') - .join('members_paid_subscription_events as mpse', function () { - this.on('mpse.subscription_id', '=', 'msce.subscription_id') - .andOn('mpse.member_id', '=', 'msce.member_id'); - }) - .where('msce.attribution_id', postId) - .where('msce.attribution_type', 'post') - .groupBy('msce.referrer_source'); + if (!['free_members', 'paid_members', 'mrr'].includes(orderField)) { + throw new errors.BadRequestError({ + message: `Invalid order field: ${orderField}. Must be one of: free_members, paid_members, mrr` + }); + } + if (!['asc', 'desc'].includes(orderDirection.toLowerCase())) { + throw new errors.BadRequestError({ + message: `Invalid order direction: ${orderDirection}` + }); + } - const map = new Map(); - for (const row of freeMembers) { - map.set(row.source, { - source: row.source, - free_members: row.free_members, - paid_members: 0, - mrr: 0 - }); - } + const freeReferrersCTE = this._buildFreeReferrersSubquery(postId, options); + const paidReferrersCTE = this._buildPaidReferrersSubquery(postId, options); + const mrrReferrersCTE = this._buildMrrReferrersSubquery(postId, options); - for (const row of paidMembers) { - const existing = map.get(row.source) ?? { - source: row.source, - free_members: 0, - paid_members: 0, - mrr: 0 - }; - existing.paid_members = row.paid_members; - map.set(row.source, existing); - } + const baseReferrersQuery = this.knex('members_created_events as mce') + .select('mce.referrer_source as source') + .where('mce.attribution_id', postId) + .where('mce.attribution_type', 'post') + .union((qb) => { + qb.select('msce.referrer_source as source') + .from('members_subscription_created_events as msce') + .where('msce.attribution_id', postId) + .where('msce.attribution_type', 'post'); + }); - for (const row of mrr) { - const existing = map.get(row.source) ?? { - source: row.source, - free_members: 0, - paid_members: 0, - mrr: 0 - }; - existing.mrr = row.mrr; - map.set(row.source, existing); - } + let query = this.knex + .with('free_referrers', freeReferrersCTE) + .with('paid_referrers', paidReferrersCTE) + .with('mrr_referrers', mrrReferrersCTE) + .with('all_referrers', baseReferrersQuery) + .select( + 'ar.source', + this.knex.raw('COALESCE(fr.free_members, 0) as free_members'), + this.knex.raw('COALESCE(pr.paid_members, 0) as paid_members'), + this.knex.raw('COALESCE(mr.mrr, 0) as mrr') + ) + .from('all_referrers as ar') + .leftJoin('free_referrers as fr', 'ar.source', 'fr.source') + .leftJoin('paid_referrers as pr', 'ar.source', 'pr.source') + .leftJoin('mrr_referrers as mr', 'ar.source', 'mr.source') + .whereNotNull('ar.source'); - const results = [...map.values()].sort((a, b) => b.mrr - a.mrr); + const results = await query + .orderBy(orderField, orderDirection) + .limit(limit); - return {data: results}; + return {data: results}; + } catch (error) { + logging.error(`Error fetching referrers for post ${postId}:`, error); + return {data: []}; + } } /** - * Build a subquery/CTE for free_members count + * Build a subquery/CTE for free_members count (Post-level) * (Signed up on Post, Paid Elsewhere/Never) * @private - * @param {TopPostsOptions} options + * @param {StatsServiceOptions} options * @returns {import('knex').Knex.QueryBuilder} */ _buildFreeMembersSubquery(options) { const knex = this.knex; - // Find members who signed up via this post (mce.attribution_id) - // but did NOT have a paid conversion via the SAME post (msce.attribution_id) let subquery = knex('members_created_events as mce') .select('mce.attribution_id as post_id') .countDistinct('mce.member_id as free_members') .leftJoin('members_subscription_created_events as msce', function () { this.on('mce.member_id', '=', 'msce.member_id') - .andOn('mce.attribution_id', '=', 'msce.attribution_id') // Important: check paid conversion attributed to SAME post + .andOn('mce.attribution_id', '=', 'msce.attribution_id') .andOnVal('msce.attribution_type', '=', 'post'); }) .where('mce.attribution_type', 'post') - .whereNull('msce.id') // Keep only those where the left join found no matching paid conversion on the same post + .whereNull('msce.id') .groupBy('mce.attribution_id'); - // Apply date filter to the signup event this._applyDateFilter(subquery, options, 'mce.created_at'); - return subquery; } /** - * Build a subquery/CTE for paid_members count + * Build a subquery/CTE for paid_members count (Post-level) * (Paid conversion attributed to this post) * @private - * @param {TopPostsOptions} options + * @param {StatsServiceOptions} options * @returns {import('knex').Knex.QueryBuilder} */ _buildPaidMembersSubquery(options) { const knex = this.knex; - // Count distinct members for whom a paid conversion event (subscription creation) - // was attributed to this post_id. let subquery = knex('members_subscription_created_events as msce') .select('msce.attribution_id as post_id') .countDistinct('msce.member_id as paid_members') .where('msce.attribution_type', 'post') .groupBy('msce.attribution_id'); - // Apply date filter to the paid conversion event timestamp this._applyDateFilter(subquery, options, 'msce.created_at'); - return subquery; } /** - * Build a subquery/CTE for mrr sum + * Build a subquery/CTE for mrr sum (Post-level) * (Paid Conversions Attributed to Post) * @private - * @param {TopPostsOptions} options + * @param {StatsServiceOptions} options * @returns {import('knex').Knex.QueryBuilder} */ _buildMrrSubquery(options) { - // Logic remains the same: Sum MRR for all paid conversions attributed to the post let subquery = this.knex('members_subscription_created_events as msce') .select('msce.attribution_id as post_id') .sum('mpse.mrr_delta as mrr') @@ -233,9 +221,88 @@ class PostsStatsService { .where('msce.attribution_type', 'post') .groupBy('msce.attribution_id'); + this._applyDateFilter(subquery, options, 'msce.created_at'); + return subquery; + } + + // --- Subqueries for getReferrersForPost --- + + /** + * Build subquery for free members count per referrer for a specific post. + * (Signed up via Post/Referrer, Did NOT convert via SAME Post/Referrer) + * @private + * @param {string} postId + * @param {StatsServiceOptions} options + * @returns {import('knex').Knex.QueryBuilder} + */ + _buildFreeReferrersSubquery(postId, options) { + const knex = this.knex; + + // Simpler approach mirroring _buildFreeMembersSubquery + let subquery = knex('members_created_events as mce') + .select('mce.referrer_source as source') + .countDistinct('mce.member_id as free_members') + .leftJoin('members_subscription_created_events as msce', function () { + this.on('mce.member_id', '=', 'msce.member_id') + .andOn('mce.attribution_id', '=', 'msce.attribution_id') // Conversion must be for the SAME post + .andOn('mce.referrer_source', '=', 'msce.referrer_source') // And the SAME referrer + .andOnVal('msce.attribution_type', '=', 'post'); + }) + .where('mce.attribution_id', postId) + .where('mce.attribution_type', 'post') + .whereNull('msce.id') // Keep only signups where no matching paid conversion (same post/referrer) exists + .groupBy('mce.referrer_source'); + + this._applyDateFilter(subquery, options, 'mce.created_at'); // Filter based on signup time + return subquery; + } + + /** + * Build subquery for paid members count per referrer for a specific post. + * (Paid conversion attributed to this Post/Referrer) + * @private + * @param {string} postId + * @param {StatsServiceOptions} options + * @returns {import('knex').Knex.QueryBuilder} + */ + _buildPaidReferrersSubquery(postId, options) { + const knex = this.knex; + let subquery = knex('members_subscription_created_events as msce') + .select('msce.referrer_source as source') + .countDistinct('msce.member_id as paid_members') + .where('msce.attribution_id', postId) + .where('msce.attribution_type', 'post') + .groupBy('msce.referrer_source'); + // Apply date filter to the paid conversion event timestamp this._applyDateFilter(subquery, options, 'msce.created_at'); + return subquery; + } + /** + * Build subquery for MRR sum per referrer for a specific post. + * (MRR from paid conversions attributed to this Post/Referrer) + * @private + * @param {string} postId + * @param {StatsServiceOptions} options + * @returns {import('knex').Knex.QueryBuilder} + */ + _buildMrrReferrersSubquery(postId, options) { + const knex = this.knex; + let subquery = knex('members_subscription_created_events as msce') + .select('msce.referrer_source as source') + .sum('mpse.mrr_delta as mrr') + .join('members_paid_subscription_events as mpse', function () { + this.on('mpse.subscription_id', '=', 'msce.subscription_id'); + // Ensure we join on member_id as well for accuracy if subscription_id isn't unique across members? (Safeguard) + this.andOn('mpse.member_id', '=', 'msce.member_id'); + }) + .where('msce.attribution_id', postId) + .where('msce.attribution_type', 'post') + .groupBy('msce.referrer_source'); + + // Apply date filter to the paid conversion event timestamp + this._applyDateFilter(subquery, options, 'msce.created_at'); return subquery; } @@ -243,17 +310,37 @@ class PostsStatsService { * Apply date filters to a query builder instance * @private * @param {import('knex').Knex.QueryBuilder} query - * @param {TopPostsOptions} options - * @param {string} [dateColumn='created_at'] - The date column to filter on + * @param {StatsServiceOptions} options + * @param {string} dateColumn - The date column to filter on */ - _applyDateFilter(query, options, dateColumn = 'created_at') { + _applyDateFilter(query, options, dateColumn) { // Note: Timezone handling might require converting dates before querying, // depending on how created_at is stored (UTC assumed here). if (options.date_from) { - query.where(dateColumn, '>=', options.date_from); + try { + // Attempt to parse and validate the date + const fromDate = new Date(options.date_from); + if (!isNaN(fromDate.getTime())) { + query.where(dateColumn, '>=', options.date_from); + } else { + logging.warn(`Invalid date_from format: ${options.date_from}. Skipping filter.`); + } + } catch (e) { + logging.warn(`Error parsing date_from: ${options.date_from}. Skipping filter.`); + } } if (options.date_to) { - query.where(dateColumn, '<=', options.date_to + ' 23:59:59'); + try { + const toDate = new Date(options.date_to); + if (!isNaN(toDate.getTime())) { + // Include the whole day for the 'to' date + query.where(dateColumn, '<=', options.date_to + ' 23:59:59'); + } else { + logging.warn(`Invalid date_to format: ${options.date_to}. Skipping filter.`); + } + } catch (e) { + logging.warn(`Error parsing date_to: ${options.date_to}. Skipping filter.`); + } } } } diff --git a/ghost/core/test/unit/server/services/stats/posts.test.js b/ghost/core/test/unit/server/services/stats/posts.test.js index d7215315fa5..974708d28dd 100644 --- a/ghost/core/test/unit/server/services/stats/posts.test.js +++ b/ghost/core/test/unit/server/services/stats/posts.test.js @@ -92,7 +92,7 @@ describe('PostsStatsService', function () { subscriptionIdCounter += 1; const finalSubscriptionId = subscriptionId || `sub_${subscriptionIdCounter}`; await _createFreeSignupEvent(postId, finalMemberId, referrerSource); - await _createPaidConversionEvent(postId, finalMemberId, finalSubscriptionId, mrr); + await _createPaidConversionEvent(postId, finalMemberId, finalSubscriptionId, mrr, referrerSource); } async function _createPaidConversion(signupPostId, conversionPostId, mrr, referrerSource, memberId = null, subscriptionId = null) { @@ -101,7 +101,7 @@ describe('PostsStatsService', function () { subscriptionIdCounter += 1; const finalSubscriptionId = subscriptionId || `sub_${subscriptionIdCounter}`; await _createFreeSignupEvent(signupPostId, finalMemberId, referrerSource); - await _createPaidConversionEvent(conversionPostId, finalMemberId, finalSubscriptionId, mrr); + await _createPaidConversionEvent(conversionPostId, finalMemberId, finalSubscriptionId, mrr, referrerSource); } before(async function () { @@ -325,9 +325,9 @@ describe('PostsStatsService', function () { }); }); - describe('getForPostAlpha', function () { + describe('getReferrersForPost', function () { it('returns empty array when no events exist', async function () { - const result = await service.getForPostAlpha('post1'); + const result = await service.getReferrersForPost('post1'); assert.ok(result.data, 'Result should have a data property'); assert.equal(result.data.length, 0, 'Should return empty array when no events exist'); }); @@ -337,17 +337,106 @@ describe('PostsStatsService', function () { await _createFreeSignupEvent('post1', 'member_2', 'referrer_2', new Date()); await _createFreeSignupEvent('post1', 'member_3', 'referrer_3', new Date()); - const result = await service.getForPostAlpha('post1'); + const result = await service.getReferrersForPost('post1'); assert.ok(result.data, 'Result should have a data property'); assert.equal(result.data.length, 3, 'Should return 3 referrers'); }); - it.skip('correctly ranks posts by paid_members', async function () {}); + it('correctly ranks referrers by free_members', async function () { + await _createFreeSignupEvent('post1', 'member_1_1', 'referrer_1'); + await _createFreeSignupEvent('post1', 'member_1_2', 'referrer_1'); + await _createPaidSignup('post1', 500, 'referrer_1', 'member_1_3'); + await _createFreeSignupEvent('post1', 'member_2_1', 'referrer_2'); + await _createPaidSignup('post1', 1000, 'referrer_3', 'member_3_1'); + await _createPaidConversion('post1', 'post2', 500, 'referrer_4', 'member_4_1'); - it.skip('correctly ranks posts by mrr', async function () {}); + const result = await service.getReferrersForPost('post1', {order: 'free_members desc'}); - it.skip('properly filters by date range', async function () {}); + assert.ok(result.data, 'Result should have a data property'); + assert.equal(result.data.length, 4, 'Should return all 4 referrers for post1'); + + const expectedResults = [ + {source: 'referrer_1', free_members: 2, paid_members: 1, mrr: 500}, + {source: 'referrer_2', free_members: 1, paid_members: 0, mrr: 0}, + {source: 'referrer_4', free_members: 1, paid_members: 0, mrr: 0}, + {source: 'referrer_3', free_members: 0, paid_members: 1, mrr: 1000} + ]; + + const sortFn = (a, b) => { + if (b.free_members !== a.free_members) { + return b.free_members - a.free_members; + } + return (a.source || '').localeCompare(b.source || ''); + }; + + const sortedActual = result.data.sort(sortFn); + const sortedExpected = expectedResults.sort(sortFn); + + assert.deepEqual(sortedActual, sortedExpected, 'Results should match expected order and counts for free_members desc'); + }); + + it('correctly ranks referrers by paid_members', async function () { + await _createPaidSignup('post1', 500, 'referrer_1', 'member_1_1'); + await _createPaidSignup('post1', 600, 'referrer_1', 'member_1_2'); + await _createPaidConversion('post2', 'post1', 700, 'referrer_2', 'member_2_1'); + await _createFreeSignupEvent('post1', 'member_3_1', 'referrer_3'); + await _createPaidConversion('post1', 'post2', 800, 'referrer_4', 'member_4_1'); + + const result = await service.getReferrersForPost('post1', {order: 'paid_members desc'}); + + assert.ok(result.data, 'Result should have a data property'); + assert.equal(result.data.length, 4, 'Should return all 4 referrers for post1'); + + const expectedResults = [ + {source: 'referrer_1', free_members: 0, paid_members: 2, mrr: 1100}, + {source: 'referrer_2', free_members: 0, paid_members: 1, mrr: 700}, + {source: 'referrer_3', free_members: 1, paid_members: 0, mrr: 0}, + {source: 'referrer_4', free_members: 1, paid_members: 0, mrr: 0} + ]; - it.skip('respects the limit parameter', async function () {}); + const sortFn = (a, b) => { + if (b.paid_members !== a.paid_members) { + return b.paid_members - a.paid_members; + } + return (a.source || '').localeCompare(b.source || ''); + }; + + const sortedActual = result.data.sort(sortFn); + const sortedExpected = expectedResults.sort(sortFn); + + assert.deepEqual(sortedActual, sortedExpected, 'Results should match expected order and counts for paid_members desc'); + }); + + it('correctly ranks referrers by mrr', async function () { + await _createPaidSignup('post1', 500, 'referrer_1', 'member_1_1'); + await _createPaidSignup('post1', 600, 'referrer_1', 'member_1_2'); + await _createPaidConversion('post2', 'post1', 1200, 'referrer_2', 'member_2_1'); + await _createFreeSignupEvent('post1', 'member_3_1', 'referrer_3'); + await _createPaidConversion('post1', 'post2', 800, 'referrer_4', 'member_4_1'); + + const result = await service.getReferrersForPost('post1', {order: 'mrr desc'}); + + assert.ok(result.data, 'Result should have a data property'); + assert.equal(result.data.length, 4, 'Should return all 4 referrers for post1'); + + const expectedResults = [ + {source: 'referrer_2', free_members: 0, paid_members: 1, mrr: 1200}, + {source: 'referrer_1', free_members: 0, paid_members: 2, mrr: 1100}, + {source: 'referrer_3', free_members: 1, paid_members: 0, mrr: 0}, + {source: 'referrer_4', free_members: 1, paid_members: 0, mrr: 0} + ]; + + const sortFn = (a, b) => { + if (b.mrr !== a.mrr) { + return b.mrr - a.mrr; + } + return (a.source || '').localeCompare(b.source || ''); + }; + + const sortedActual = result.data.sort(sortFn); + const sortedExpected = expectedResults.sort(sortFn); + + assert.deepEqual(sortedActual, sortedExpected, 'Results should match expected order and counts for mrr desc'); + }); }); }); From 7231348a01609053883fb1cd5ba2705932cbc7f1 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 16:55:57 -0700 Subject: [PATCH 07/16] Added test for date filtering --- .../unit/server/services/stats/posts.test.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/ghost/core/test/unit/server/services/stats/posts.test.js b/ghost/core/test/unit/server/services/stats/posts.test.js index 974708d28dd..769e80501cf 100644 --- a/ghost/core/test/unit/server/services/stats/posts.test.js +++ b/ghost/core/test/unit/server/services/stats/posts.test.js @@ -438,5 +438,32 @@ describe('PostsStatsService', function () { assert.deepEqual(sortedActual, sortedExpected, 'Results should match expected order and counts for mrr desc'); }); + + it('properly filters by date range', async function () { + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + const fifteenDaysAgo = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); + + // Create events at different dates + await _createFreeSignupEvent('post1', 'member_past', 'referrer_past', tenDaysAgo); + await _createFreeSignupEvent('post1', 'member_future', 'referrer_future', thirtyDaysAgo); + + const lastFifteenDaysResult = await service.getReferrersForPost('post1', { + date_from: fifteenDaysAgo, + date_to: new Date() + }); + + assert.equal(lastFifteenDaysResult.data.find(r => r.source === 'referrer_past').free_members, 1); + + // Test filtering to include both dates + const lastThirtyDaysResult = await service.getReferrersForPost('post1', { + date_from: sixtyDaysAgo, + date_to: new Date() + }); + + assert.equal(lastThirtyDaysResult.data.find(r => r.source === 'referrer_past').free_members, 1); + assert.equal(lastThirtyDaysResult.data.find(r => r.source === 'referrer_future').free_members, 1); + }); }); }); From 16971418fbb1fc931458814fe80b641097af4b0e Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 16:56:41 -0700 Subject: [PATCH 08/16] Added test for the limit option --- .../unit/server/services/stats/posts.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ghost/core/test/unit/server/services/stats/posts.test.js b/ghost/core/test/unit/server/services/stats/posts.test.js index 769e80501cf..5ed28676a69 100644 --- a/ghost/core/test/unit/server/services/stats/posts.test.js +++ b/ghost/core/test/unit/server/services/stats/posts.test.js @@ -465,5 +465,23 @@ describe('PostsStatsService', function () { assert.equal(lastThirtyDaysResult.data.find(r => r.source === 'referrer_past').free_members, 1); assert.equal(lastThirtyDaysResult.data.find(r => r.source === 'referrer_future').free_members, 1); }); + + it('respects the limit parameter', async function () { + await _createFreeSignupEvent('post1', 'member_1', 'referrer_1', new Date()); + await _createFreeSignupEvent('post1', 'member_2', 'referrer_2', new Date()); + await _createFreeSignupEvent('post1', 'member_3', 'referrer_3', new Date()); + + const result = await service.getReferrersForPost('post1', { + order: 'free_members desc', + limit: 2 + }); + + assert.ok(result.data, 'Result should have a data property'); + assert.equal(result.data.length, 2, 'Should return only 2 referrers'); + + // Verify that only the top 2 referrers by free_members are returned + assert.equal(result.data[0].source, 'referrer_1'); + assert.equal(result.data[1].source, 'referrer_2'); + }); }); }); From 5474b6f4d486ad99c59ae62ae3ea7144a0313739 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 17:34:16 -0700 Subject: [PATCH 09/16] Replaced hardcoded postId with parameter --- apps/posts/src/views/PostAnalytics/Growth.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/posts/src/views/PostAnalytics/Growth.tsx b/apps/posts/src/views/PostAnalytics/Growth.tsx index 8313b941fd2..e6ad8a477f1 100644 --- a/apps/posts/src/views/PostAnalytics/Growth.tsx +++ b/apps/posts/src/views/PostAnalytics/Growth.tsx @@ -5,14 +5,15 @@ import PostAnalyticsHeader from './components/PostAnalyticsHeader'; import PostAnalyticsLayout from './layout/PostAnalyticsLayout'; import {Card, CardContent, CardDescription, CardHeader, CardTitle, LucideIcon, Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, ViewHeader, ViewHeaderActions, formatNumber} from '@tryghost/shade'; import {usePostReferrers} from '../../hooks/usePostReferrers'; +import {useParams} from '@tryghost/admin-x-framework'; const STATS_DEFAULT_SOURCE_ICON_URL = 'https://static.ghost.org/v5.0.0/images/globe-icon.svg'; interface postAnalyticsProps {} const Growth: React.FC = () => { // const {isLoading: isConfigLoading} = useGlobalData(); - - const {stats: postReferrers, isLoading, totals} = usePostReferrers('000000006314fec72721973c'); + const {postId} = useParams(); + const {stats: postReferrers, isLoading, totals} = usePostReferrers(postId || ''); // const {range} = useGlobalData(); return ( From a2ea7a5b6f59655811a6f395bd6b3e52896ac9af Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 17:36:54 -0700 Subject: [PATCH 10/16] Moved endpoint from /stats/referrers/posts/:id/alpha to /stats/posts/:id/top-referrers --- apps/admin-x-framework/src/api/stats.ts | 2 +- ghost/core/core/server/web/api/endpoints/admin/routes.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/admin-x-framework/src/api/stats.ts b/apps/admin-x-framework/src/api/stats.ts index 8b6619e400e..e1a23bd3166 100644 --- a/apps/admin-x-framework/src/api/stats.ts +++ b/apps/admin-x-framework/src/api/stats.ts @@ -83,5 +83,5 @@ export const useTopPostsStats = createQuery({ export const usePostReferrers = createQueryWithId({ dataType: postReferrersDataType, - path: id => `/stats/referrers/posts/${id}/alpha` + path: id => `/stats/posts/${id}/top-referrers` }); diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 00f1baee85f..02810c61d71 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -158,7 +158,7 @@ module.exports = function apiRoutes() { if (labs.isSet('trafficAnalytics')) { router.get('/stats/top-posts', mw.authAdminApi, http(api.stats.topPosts)); router.get('/stats/top-content', mw.authAdminApi, http(api.stats.topContent)); - router.get('/stats/referrers/posts/:id/alpha', mw.authAdminApi, http(api.stats.postReferrersAlpha)); + router.get('/stats/posts/:id/top-referrers', mw.authAdminApi, http(api.stats.postReferrersAlpha)); } // ## Labels From e8f700fda15eead82d62fde68642aa793040144a Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 17:45:09 -0700 Subject: [PATCH 11/16] Added an e2e smoke test --- .../admin/__snapshots__/stats.test.js.snap | 13 +++++++++++++ ghost/core/test/e2e-api/admin/stats.test.js | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap index 99586c80f7d..4319a2e3e6e 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/stats.test.js.snap @@ -465,3 +465,16 @@ Object { "x-powered-by": "Express", } `; + +exports[`Stats API Top Referrers for a post Can fetch top referrers for a post 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": StringMatching /\\\\d\\+/, + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/stats.test.js b/ghost/core/test/e2e-api/admin/stats.test.js index 6f2def0b3e2..980d4de9b57 100644 --- a/ghost/core/test/e2e-api/admin/stats.test.js +++ b/ghost/core/test/e2e-api/admin/stats.test.js @@ -245,4 +245,21 @@ describe('Stats API', function () { }); }); }); + + describe('Top Referrers for a post', function () { + it('Can fetch top referrers for a post', async function () { + await agent + .get(`/stats/posts/${fixtureManager.get('posts', 1).id}/top-referrers`) + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + 'content-length': anyContentLength, + etag: anyEtag + }) + .expect(({body}) => { + assert.ok(body.stats, 'Response should contain a stats property'); + assert.ok(Array.isArray(body.stats), 'body.stats should be an array'); + }); + }); + }); }); From de693587c6e7107e88394bfa1560d401b4021cbf Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 17:51:49 -0700 Subject: [PATCH 12/16] fixup! Added an e2e smoke test --- apps/posts/src/views/PostAnalytics/Growth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/posts/src/views/PostAnalytics/Growth.tsx b/apps/posts/src/views/PostAnalytics/Growth.tsx index e6ad8a477f1..200848d8a2a 100644 --- a/apps/posts/src/views/PostAnalytics/Growth.tsx +++ b/apps/posts/src/views/PostAnalytics/Growth.tsx @@ -4,8 +4,8 @@ import PostAnalyticsContent from './components/PostAnalyticsContent'; import PostAnalyticsHeader from './components/PostAnalyticsHeader'; import PostAnalyticsLayout from './layout/PostAnalyticsLayout'; import {Card, CardContent, CardDescription, CardHeader, CardTitle, LucideIcon, Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, ViewHeader, ViewHeaderActions, formatNumber} from '@tryghost/shade'; -import {usePostReferrers} from '../../hooks/usePostReferrers'; import {useParams} from '@tryghost/admin-x-framework'; +import {usePostReferrers} from '../../hooks/usePostReferrers'; const STATS_DEFAULT_SOURCE_ICON_URL = 'https://static.ghost.org/v5.0.0/images/globe-icon.svg'; interface postAnalyticsProps {} From 9c50a450519e48ff6072800bc7b63997492b544f Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 18:43:46 -0700 Subject: [PATCH 13/16] Added validation for post id --- ghost/core/core/server/api/endpoints/stats.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ghost/core/core/server/api/endpoints/stats.js b/ghost/core/core/server/api/endpoints/stats.js index b1def40dfbe..d9effc47748 100644 --- a/ghost/core/core/server/api/endpoints/stats.js +++ b/ghost/core/core/server/api/endpoints/stats.js @@ -182,6 +182,14 @@ const controller = { data: [ 'id' ], + validation: { + data: { + id: { + type: 'string', + required: true + } + } + }, permissions: { docName: 'posts', method: 'browse' From f21ab8c4fbb5c12e7737a40984cb5c4e944f10ab Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 18:47:13 -0700 Subject: [PATCH 14/16] Reset totals on post analytics to static values --- apps/posts/src/hooks/usePostReferrers.ts | 29 +------------------ apps/posts/src/views/PostAnalytics/Growth.tsx | 8 ++--- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/apps/posts/src/hooks/usePostReferrers.ts b/apps/posts/src/hooks/usePostReferrers.ts index 490d464c352..3f6da2979a7 100644 --- a/apps/posts/src/hooks/usePostReferrers.ts +++ b/apps/posts/src/hooks/usePostReferrers.ts @@ -1,39 +1,12 @@ import {PostReferrerStatItem, usePostReferrers as usePostReferrersAPI} from '@tryghost/admin-x-framework/api/stats'; import {useMemo} from 'react'; -// Calculate totals from referrer data -const calculateTotals = (referrerData: PostReferrerStatItem[]) => { - if (!referrerData.length) { - return { - free_members: 0, - paid_members: 0, - mrr: 0 - }; - } - - const totals = referrerData.reduce((acc, item) => { - acc.free_members += item.free_members || 0; - acc.paid_members += item.paid_members || 0; - acc.mrr += item.mrr || 0; - return acc; - }, {free_members: 0, paid_members: 0, mrr: 0}); - - return totals; -}; - export const usePostReferrers = (postId: string) => { - // Fetch post referrer data from API const {data: postReferrerResponse, isLoading} = usePostReferrersAPI(postId); - - // Extract the stats data const stats = useMemo(() => postReferrerResponse?.stats || [], [postReferrerResponse]); - // Calculate totals - const totals = useMemo(() => calculateTotals(stats), [stats]); - return { isLoading, - stats, - totals + stats }; }; diff --git a/apps/posts/src/views/PostAnalytics/Growth.tsx b/apps/posts/src/views/PostAnalytics/Growth.tsx index 200848d8a2a..cc6d954cb19 100644 --- a/apps/posts/src/views/PostAnalytics/Growth.tsx +++ b/apps/posts/src/views/PostAnalytics/Growth.tsx @@ -13,7 +13,7 @@ interface postAnalyticsProps {} const Growth: React.FC = () => { // const {isLoading: isConfigLoading} = useGlobalData(); const {postId} = useParams(); - const {stats: postReferrers, isLoading, totals} = usePostReferrers(postId || ''); + const {stats: postReferrers, isLoading} = usePostReferrers(postId || ''); // const {range} = useGlobalData(); return ( @@ -33,21 +33,21 @@ const Growth: React.FC = () => { Free members - {formatNumber(totals.free_members)} + {formatNumber(22)} Paid members - {formatNumber(totals.paid_members)} + {formatNumber(8)} MRR - +${formatNumber(totals.mrr)} + +${formatNumber(180)} From 2f0637930086fbb3f825e5a971be7c3ebd147aa9 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 18:49:37 -0700 Subject: [PATCH 15/16] fixup! Reset totals on post analytics to static values --- apps/posts/src/hooks/usePostReferrers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/posts/src/hooks/usePostReferrers.ts b/apps/posts/src/hooks/usePostReferrers.ts index 3f6da2979a7..fda1716b25f 100644 --- a/apps/posts/src/hooks/usePostReferrers.ts +++ b/apps/posts/src/hooks/usePostReferrers.ts @@ -1,4 +1,4 @@ -import {PostReferrerStatItem, usePostReferrers as usePostReferrersAPI} from '@tryghost/admin-x-framework/api/stats'; +import {usePostReferrers as usePostReferrersAPI} from '@tryghost/admin-x-framework/api/stats'; import {useMemo} from 'react'; export const usePostReferrers = (postId: string) => { From 83bb760e05ff21629b23fe87c0045e86a9873351 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 5 May 2025 19:05:54 -0700 Subject: [PATCH 16/16] fixup! fixup! Reset totals on post analytics to static values --- apps/posts/src/hooks/usePostReferrers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/posts/src/hooks/usePostReferrers.ts b/apps/posts/src/hooks/usePostReferrers.ts index fda1716b25f..c6b78bdffe4 100644 --- a/apps/posts/src/hooks/usePostReferrers.ts +++ b/apps/posts/src/hooks/usePostReferrers.ts @@ -1,5 +1,5 @@ -import {usePostReferrers as usePostReferrersAPI} from '@tryghost/admin-x-framework/api/stats'; import {useMemo} from 'react'; +import {usePostReferrers as usePostReferrersAPI} from '@tryghost/admin-x-framework/api/stats'; export const usePostReferrers = (postId: string) => { const {data: postReferrerResponse, isLoading} = usePostReferrersAPI(postId);