diff --git a/apps/admin-x-framework/src/api/stats.ts b/apps/admin-x-framework/src/api/stats.ts index 30a1f76a741f..e1a23bd3166f 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/posts/${id}/top-referrers` +}); diff --git a/apps/posts/src/hooks/usePostReferrers.ts b/apps/posts/src/hooks/usePostReferrers.ts new file mode 100644 index 000000000000..c6b78bdffe4e --- /dev/null +++ b/apps/posts/src/hooks/usePostReferrers.ts @@ -0,0 +1,12 @@ +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); + const stats = useMemo(() => postReferrerResponse?.stats || [], [postReferrerResponse]); + + return { + isLoading, + stats + }; +}; diff --git a/apps/posts/src/views/PostAnalytics/Growth.tsx b/apps/posts/src/views/PostAnalytics/Growth.tsx index 47817eae39cd..cc6d954cb197 100644 --- a/apps/posts/src/views/PostAnalytics/Growth.tsx +++ b/apps/posts/src/views/PostAnalytics/Growth.tsx @@ -4,26 +4,18 @@ 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 {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 {} const Growth: React.FC = () => { // const {isLoading: isConfigLoading} = useGlobalData(); + const {postId} = useParams(); + const {stats: postReferrers, isLoading} = usePostReferrers(postId || ''); // 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} - ]; - return ( @@ -55,7 +47,7 @@ const Growth: React.FC = () => { MRR - +$180 + +${formatNumber(180)} @@ -75,22 +67,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 baf70fa615b6..d9effc477484 100644 --- a/ghost/core/core/server/api/endpoints/stats.js +++ b/ghost/core/core/server/api/endpoints/stats.js @@ -167,6 +167,45 @@ const controller = { async query(frame) { return await statsService.api.getTopPosts(frame.options); } + }, + postReferrersAlpha: { + headers: { + cacheInvalidate: false + }, + options: [ + 'order', + 'limit', + 'date_from', + 'date_to', + 'timezone' + ], + data: [ + 'id' + ], + validation: { + data: { + id: { + type: 'string', + required: true + } + } + }, + permissions: { + docName: 'posts', + method: 'browse' + }, + cache: statsService.cache, + generateCacheKeyData(frame) { + return { + method: 'getReferrersForPost', + data: { + id: frame.data.id + } + }; + }, + async query(frame) { + 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 5276aae8e0a3..020c47e3672f 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,66 +93,124 @@ class PostsStatsService { } /** - * Build a subquery/CTE for free_members count + * Get referrers for a specific post by attribution metrics + * @param {string} postId + * @param {StatsServiceOptions} options + * @returns {Promise<{data: ReferrerStatsResult[]}>} The referrers for the post, ranked by the specified metric + */ + 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(' '); + + 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 freeReferrersCTE = this._buildFreeReferrersSubquery(postId, options); + const paidReferrersCTE = this._buildPaidReferrersSubquery(postId, options); + const mrrReferrersCTE = this._buildMrrReferrersSubquery(postId, options); + + 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'); + }); + + 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 = await query + .orderBy(orderField, orderDirection) + .limit(limit); + + return {data: results}; + } catch (error) { + logging.error(`Error fetching referrers for post ${postId}:`, error); + return {data: []}; + } + } + + /** + * 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') @@ -157,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; } @@ -167,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/core/server/services/stats/StatsService.js b/ghost/core/core/server/services/stats/StatsService.js index 61f6dc8a696f..1c269d99be92 100644 --- a/ghost/core/core/server/services/stats/StatsService.js +++ b/ghost/core/core/server/services/stats/StatsService.js @@ -63,6 +63,14 @@ class StatsService { }; } + /** + * @param {string} postId + */ + async getReferrersForPost(postId, options) { + const result = await this.posts.getReferrersForPost(postId, options); + return result; + } + /** * @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 6bb795fe89a4..02810c61d71c 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'); @@ -154,8 +155,11 @@ module.exports = function apiRoutes() { 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', 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/posts/:id/top-referrers', mw.authAdminApi, http(api.stats.postReferrersAlpha)); + } // ## Labels router.get('/labels', mw.authAdminApi, http(api.labels.browse)); 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 99586c80f7dc..4319a2e3e6e6 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 6f2def0b3e2d..980d4de9b57c 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'); + }); + }); + }); }); 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 fb3ccdb9575e..5ed28676a69e 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,28 +80,28 @@ 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 _createPaidConversionEvent(postId, finalMemberId, finalSubscriptionId, mrr); + await _createFreeSignupEvent(postId, finalMemberId, referrerSource); + await _createPaidConversionEvent(postId, finalMemberId, finalSubscriptionId, mrr, referrerSource); } - 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 _createPaidConversionEvent(conversionPostId, finalMemberId, finalSubscriptionId, mrr); + await _createFreeSignupEvent(signupPostId, finalMemberId, referrerSource); + await _createPaidConversionEvent(conversionPostId, finalMemberId, finalSubscriptionId, mrr, referrerSource); } before(async function () { @@ -122,6 +124,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 +134,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) { @@ -189,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'}); @@ -218,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'}); @@ -246,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'}); @@ -280,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, @@ -302,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', @@ -320,4 +324,164 @@ describe('PostsStatsService', function () { assert.equal(result.data[1].post_id, 'post2'); }); }); + + describe('getReferrersForPost', function () { + it('returns empty array when no events exist', async function () { + 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'); + }); + + 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.getReferrersForPost('post1'); + assert.ok(result.data, 'Result should have a data property'); + assert.equal(result.data.length, 3, 'Should return 3 referrers'); + }); + + 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'); + + const result = await service.getReferrersForPost('post1', {order: 'free_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: 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} + ]; + + 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'); + }); + + 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); + }); + + 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'); + }); + }); });