@@ -250,52 +250,6 @@ class ReferrersStatsService {
250
250
return rows ;
251
251
}
252
252
253
- /**
254
- * Fetch paid conversion sources with date range
255
- * @param {string } startDate
256
- * @param {string } endDate
257
- * @returns {Promise<PaidConversionsCountStatDate[]> }
258
- **/
259
- async fetchPaidConversionSourcesWithRange ( startDate , endDate ) {
260
- const knex = this . knex ;
261
- const startDateTime = moment . utc ( startDate ) . startOf ( 'day' ) . format ( 'YYYY-MM-DD HH:mm:ss' ) ;
262
- const endDateTime = moment . utc ( endDate ) . endOf ( 'day' ) . format ( 'YYYY-MM-DD HH:mm:ss' ) ;
263
-
264
- const rows = await knex ( 'members_subscription_created_events' )
265
- . select ( knex . raw ( `DATE(created_at) as date` ) )
266
- . select ( knex . raw ( `COUNT(*) as paid_conversions` ) )
267
- . select ( knex . raw ( `referrer_source as source` ) )
268
- . where ( 'created_at' , '>=' , startDateTime )
269
- . where ( 'created_at' , '<=' , endDateTime )
270
- . groupBy ( 'date' , 'referrer_source' )
271
- . orderBy ( 'date' ) ;
272
-
273
- return rows ;
274
- }
275
-
276
- /**
277
- * Fetch signup sources with date range
278
- * @param {string } startDate
279
- * @param {string } endDate
280
- * @returns {Promise<SignupCountStatDate[]> }
281
- **/
282
- async fetchSignupSourcesWithRange ( startDate , endDate ) {
283
- const knex = this . knex ;
284
- const startDateTime = moment . utc ( startDate ) . startOf ( 'day' ) . format ( 'YYYY-MM-DD HH:mm:ss' ) ;
285
- const endDateTime = moment . utc ( endDate ) . endOf ( 'day' ) . format ( 'YYYY-MM-DD HH:mm:ss' ) ;
286
-
287
- const rows = await knex ( 'members_created_events' )
288
- . select ( knex . raw ( `DATE(created_at) as date` ) )
289
- . select ( knex . raw ( `COUNT(*) as signups` ) )
290
- . select ( knex . raw ( `referrer_source as source` ) )
291
- . where ( 'created_at' , '>=' , startDateTime )
292
- . where ( 'created_at' , '<=' , endDateTime )
293
- . groupBy ( 'date' , 'referrer_source' )
294
- . orderBy ( 'date' ) ;
295
-
296
- return rows ;
297
- }
298
-
299
253
/**
300
254
* Fetch MRR sources with date range
301
255
* @param {string } startDate
@@ -326,6 +280,76 @@ class ReferrersStatsService {
326
280
return rows ;
327
281
}
328
282
283
+ /**
284
+ * Fetch deduplicated member counts by source with date range
285
+ * Returns both free signups (excluding those who converted) and paid conversions
286
+ * @param {string } startDate
287
+ * @param {string } endDate
288
+ * @returns {Promise<{source: string, signups: number, paid_conversions: number}[]> }
289
+ **/
290
+ async fetchMemberCountsBySource ( startDate , endDate ) {
291
+ const knex = this . knex ;
292
+ const startDateTime = moment . utc ( startDate ) . startOf ( 'day' ) . format ( 'YYYY-MM-DD HH:mm:ss' ) ;
293
+ const endDateTime = moment . utc ( endDate ) . endOf ( 'day' ) . format ( 'YYYY-MM-DD HH:mm:ss' ) ;
294
+
295
+ // Query 1: Free members who haven't converted to paid within the same time window
296
+ const freeSignupsQuery = knex ( 'members_created_events as mce' )
297
+ . select ( 'mce.referrer_source as source' )
298
+ . select ( knex . raw ( 'COUNT(DISTINCT mce.member_id) as signups' ) )
299
+ . leftJoin ( 'members_subscription_created_events as msce' , function ( ) {
300
+ this . on ( 'mce.member_id' , '=' , 'msce.member_id' )
301
+ // Only join if the conversion happened within the same time window
302
+ . andOn ( 'msce.created_at' , '>=' , knex . raw ( '?' , [ startDateTime ] ) )
303
+ . andOn ( 'msce.created_at' , '<=' , knex . raw ( '?' , [ endDateTime ] ) ) ;
304
+ } )
305
+ . where ( 'mce.created_at' , '>=' , startDateTime )
306
+ . where ( 'mce.created_at' , '<=' , endDateTime )
307
+ . whereNull ( 'msce.id' )
308
+ . groupBy ( 'mce.referrer_source' ) ;
309
+
310
+ // Query 2: Paid conversions
311
+ const paidConversionsQuery = knex ( 'members_subscription_created_events as msce' )
312
+ . select ( 'msce.referrer_source as source' )
313
+ . select ( knex . raw ( 'COUNT(DISTINCT msce.member_id) as paid_conversions' ) )
314
+ . where ( 'msce.created_at' , '>=' , startDateTime )
315
+ . where ( 'msce.created_at' , '<=' , endDateTime )
316
+ . groupBy ( 'msce.referrer_source' ) ;
317
+
318
+ // Execute both queries in parallel
319
+ const [ freeResults , paidResults ] = await Promise . all ( [
320
+ freeSignupsQuery ,
321
+ paidConversionsQuery
322
+ ] ) ;
323
+
324
+ // Combine results by source
325
+ const sourceMap = new Map ( ) ;
326
+
327
+ // Add free signups
328
+ freeResults . forEach ( ( row ) => {
329
+ sourceMap . set ( row . source , {
330
+ source : row . source ,
331
+ signups : parseInt ( row . signups ) || 0 ,
332
+ paid_conversions : 0
333
+ } ) ;
334
+ } ) ;
335
+
336
+ // Add paid conversions
337
+ paidResults . forEach ( ( row ) => {
338
+ const existing = sourceMap . get ( row . source ) ;
339
+ if ( existing ) {
340
+ existing . paid_conversions = parseInt ( row . paid_conversions ) || 0 ;
341
+ } else {
342
+ sourceMap . set ( row . source , {
343
+ source : row . source ,
344
+ signups : 0 ,
345
+ paid_conversions : parseInt ( row . paid_conversions ) || 0
346
+ } ) ;
347
+ }
348
+ } ) ;
349
+
350
+ return Array . from ( sourceMap . values ( ) ) ;
351
+ }
352
+
329
353
/**
330
354
* Return aggregated attribution sources for a date range, grouped by source only (not by date)
331
355
* This is used for "Top Sources" tables that need server-side sorting
@@ -336,35 +360,47 @@ class ReferrersStatsService {
336
360
* @returns {Promise<{data: AttributionCountStatWithMrr[], meta: {}}> }
337
361
*/
338
362
async getTopSourcesWithRange ( startDate , endDate , orderBy = 'signups desc' , limit = 50 ) {
339
- const paidConversionEntries = await this . fetchPaidConversionSourcesWithRange ( startDate , endDate ) ;
340
- const signupEntries = await this . fetchSignupSourcesWithRange ( startDate , endDate ) ;
341
- const mrrEntries = await this . fetchMrrSourcesWithRange ( startDate , endDate ) ;
363
+ // Get deduplicated member counts and MRR data in parallel
364
+ const [ memberCounts , mrrEntries ] = await Promise . all ( [
365
+ this . fetchMemberCountsBySource ( startDate , endDate ) ,
366
+ this . fetchMrrSourcesWithRange ( startDate , endDate )
367
+ ] ) ;
342
368
343
369
// Aggregate by source (not by date + source)
344
370
const sourceMap = new Map ( ) ;
345
371
346
- // Add signup data
347
- signupEntries . forEach ( ( entry ) => {
348
- const source = normalizeSource ( entry . source ) ;
349
- const existing = sourceMap . get ( source ) || { source, signups : 0 , paid_conversions : 0 , mrr : 0 } ;
350
- existing . signups += entry . signups ;
351
- sourceMap . set ( source , existing ) ;
352
- } ) ;
353
-
354
- // Add paid conversion data
355
- paidConversionEntries . forEach ( ( entry ) => {
372
+ // Add member counts (both signups and paid conversions)
373
+ memberCounts . forEach ( ( entry ) => {
356
374
const source = normalizeSource ( entry . source ) ;
357
- const existing = sourceMap . get ( source ) || { source, signups : 0 , paid_conversions : 0 , mrr : 0 } ;
358
- existing . paid_conversions += entry . paid_conversions ;
359
- sourceMap . set ( source , existing ) ;
375
+ const existing = sourceMap . get ( source ) ;
376
+ if ( existing ) {
377
+ // Aggregate if the normalized source already exists (e.g., multiple null/empty values)
378
+ existing . signups += entry . signups ;
379
+ existing . paid_conversions += entry . paid_conversions ;
380
+ } else {
381
+ sourceMap . set ( source , {
382
+ source,
383
+ signups : entry . signups ,
384
+ paid_conversions : entry . paid_conversions ,
385
+ mrr : 0
386
+ } ) ;
387
+ }
360
388
} ) ;
361
389
362
390
// Add MRR data
363
391
mrrEntries . forEach ( ( entry ) => {
364
392
const source = normalizeSource ( entry . source ) ;
365
- const existing = sourceMap . get ( source ) || { source, signups : 0 , paid_conversions : 0 , mrr : 0 } ;
366
- existing . mrr += entry . mrr ;
367
- sourceMap . set ( source , existing ) ;
393
+ const existing = sourceMap . get ( source ) ;
394
+ if ( existing ) {
395
+ existing . mrr += entry . mrr ;
396
+ } else {
397
+ sourceMap . set ( source , {
398
+ source,
399
+ signups : 0 ,
400
+ paid_conversions : 0 ,
401
+ mrr : entry . mrr
402
+ } ) ;
403
+ }
368
404
} ) ;
369
405
370
406
// Convert to array and sort
0 commit comments