ghost 6.3.0 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/tryghost-i18n-6.4.0.tgz +0 -0
- package/core/built/admin/assets/activitypub/activitypub.js +6 -0
- package/core/built/admin/assets/{admin-x-activitypub/index-C8tyOPu-.mjs → activitypub/index-2Xkl6sDz.mjs} +2 -2
- package/core/built/admin/assets/{admin-x-activitypub/index-QqbAPyqT.mjs → activitypub/index-CKBuhv6F.mjs} +13906 -13199
- package/core/built/admin/assets/{chunk.524.aac61953956de04feb53.js → chunk.524.5fd100e92f0b07a59e66.js} +6 -6
- package/core/built/admin/assets/{chunk.582.0a1461429ddbaef85ea9.js → chunk.582.411877f4da4644562f10.js} +8 -8
- package/core/built/admin/assets/{ghost-1bfab97cb7f550726e894fae6650a808.js → ghost-e7b2c10bfc27fe1ef28d2697e9e7551b.js} +20 -38
- package/core/built/admin/assets/posts/posts.js +5529 -5511
- package/core/built/admin/assets/stats/stats.js +20 -20
- package/core/built/admin/index.html +3 -3
- package/core/frontend/helpers/reading_time.js +4 -4
- package/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js +8 -3
- package/core/server/data/migrations/versions/6.4/2025-10-13-10-18-38-add-tokens-otc-used-count-column.js +8 -0
- package/core/server/data/schema/schema.js +3 -2
- package/core/server/models/single-use-token.js +1 -0
- package/core/server/services/email-service/email-templates/template.hbs +0 -2
- package/core/server/services/lib/MailgunClient.js +2 -1
- package/core/server/services/lib/magic-link/MagicLink.js +0 -1
- package/core/server/services/mail/GhostMailer.js +21 -0
- package/core/server/services/members/SingleUseTokenProvider.js +218 -62
- package/core/server/services/stats/ReferrersStatsService.js +237 -111
- package/core/server/services/stats/StatsService.js +4 -4
- package/package.json +6 -6
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +381 -243
- package/components/tryghost-i18n-6.3.0.tgz +0 -0
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +0 -6
- package/core/server/services/lib/magic-link/JWTTokenProvider.js +0 -52
- package/core/server/services/lib/magic-link/README.md +0 -60
- /package/core/built/admin/assets/{admin-x-activitypub → activitypub}/styles/reader.css +0 -0
|
@@ -262,7 +262,7 @@ class ReferrersStatsService {
|
|
|
262
262
|
async fetchMrrSourcesWithRange(options) {
|
|
263
263
|
const knex = this.knex;
|
|
264
264
|
const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
|
|
265
|
-
|
|
265
|
+
|
|
266
266
|
// Join subscription created events with paid subscription events to get MRR changes
|
|
267
267
|
let query = knex('members_subscription_created_events as msce')
|
|
268
268
|
.join('members_paid_subscription_events as mpse', function () {
|
|
@@ -276,10 +276,10 @@ class ReferrersStatsService {
|
|
|
276
276
|
.whereNotNull('msce.referrer_source') // Only entries with attribution
|
|
277
277
|
.groupBy('date', 'msce.referrer_source')
|
|
278
278
|
.orderBy('date');
|
|
279
|
-
|
|
279
|
+
|
|
280
280
|
// Apply centralized date filtering
|
|
281
281
|
applyDateFilter(query, startDateTime, endDateTime, 'msce.created_at');
|
|
282
|
-
|
|
282
|
+
|
|
283
283
|
const rows = await query;
|
|
284
284
|
|
|
285
285
|
return rows;
|
|
@@ -294,7 +294,7 @@ class ReferrersStatsService {
|
|
|
294
294
|
async fetchMemberCountsBySource(options) {
|
|
295
295
|
const knex = this.knex;
|
|
296
296
|
const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
|
|
297
|
-
|
|
297
|
+
|
|
298
298
|
// Query 1: Free members who haven't converted to paid within the same time window
|
|
299
299
|
const freeSignupsQuery = knex('members_created_events as mce')
|
|
300
300
|
.select('mce.referrer_source as source')
|
|
@@ -307,7 +307,7 @@ class ReferrersStatsService {
|
|
|
307
307
|
})
|
|
308
308
|
.whereNull('msce.id')
|
|
309
309
|
.groupBy('mce.referrer_source');
|
|
310
|
-
|
|
310
|
+
|
|
311
311
|
// Apply date filtering to the main query
|
|
312
312
|
applyDateFilter(freeSignupsQuery, startDateTime, endDateTime, 'mce.created_at');
|
|
313
313
|
|
|
@@ -316,7 +316,7 @@ class ReferrersStatsService {
|
|
|
316
316
|
.select('msce.referrer_source as source')
|
|
317
317
|
.select(knex.raw('COUNT(DISTINCT msce.member_id) as paid_conversions'))
|
|
318
318
|
.groupBy('msce.referrer_source');
|
|
319
|
-
|
|
319
|
+
|
|
320
320
|
// Apply date filtering to the paid conversions query
|
|
321
321
|
applyDateFilter(paidConversionsQuery, startDateTime, endDateTime, 'msce.created_at');
|
|
322
322
|
|
|
@@ -328,7 +328,7 @@ class ReferrersStatsService {
|
|
|
328
328
|
|
|
329
329
|
// Combine results by source
|
|
330
330
|
const sourceMap = new Map();
|
|
331
|
-
|
|
331
|
+
|
|
332
332
|
// Add free signups
|
|
333
333
|
freeResults.forEach((row) => {
|
|
334
334
|
sourceMap.set(row.source, {
|
|
@@ -337,7 +337,7 @@ class ReferrersStatsService {
|
|
|
337
337
|
paid_conversions: 0
|
|
338
338
|
});
|
|
339
339
|
});
|
|
340
|
-
|
|
340
|
+
|
|
341
341
|
// Add paid conversions
|
|
342
342
|
paidResults.forEach((row) => {
|
|
343
343
|
const existing = sourceMap.get(row.source);
|
|
@@ -368,11 +368,11 @@ class ReferrersStatsService {
|
|
|
368
368
|
*/
|
|
369
369
|
async getTopSourcesWithRange(options = {}) {
|
|
370
370
|
const {orderBy = 'signups desc', limit = 50} = options;
|
|
371
|
-
|
|
371
|
+
|
|
372
372
|
// Get deduplicated member counts and MRR data in parallel
|
|
373
373
|
const [memberCounts, mrrEntries] = await Promise.all([
|
|
374
374
|
this.fetchMemberCountsBySource(options),
|
|
375
|
-
this.fetchMrrSourcesWithRange(options)
|
|
375
|
+
this.fetchMrrSourcesWithRange(options)
|
|
376
376
|
]);
|
|
377
377
|
|
|
378
378
|
// Aggregate by source (not by date + source)
|
|
@@ -417,10 +417,10 @@ class ReferrersStatsService {
|
|
|
417
417
|
|
|
418
418
|
// Apply sorting - only allow descending sorts for sources
|
|
419
419
|
const [field] = orderBy.split(' ');
|
|
420
|
-
|
|
420
|
+
|
|
421
421
|
results.sort((a, b) => {
|
|
422
422
|
let valueA; let valueB;
|
|
423
|
-
|
|
423
|
+
|
|
424
424
|
switch (field) {
|
|
425
425
|
case 'signups':
|
|
426
426
|
valueA = a.signups;
|
|
@@ -458,11 +458,156 @@ class ReferrersStatsService {
|
|
|
458
458
|
}
|
|
459
459
|
|
|
460
460
|
/**
|
|
461
|
-
*
|
|
461
|
+
* Fetch free member counts by UTM parameter with date range
|
|
462
|
+
* Returns members who haven't converted to paid within the same time window
|
|
463
|
+
* @param {string} utmField - The UTM field to group by
|
|
464
|
+
* @param {Object} options - Query options
|
|
465
|
+
* @returns {Promise<{utm_value: string, signups: number}[]>}
|
|
466
|
+
**/
|
|
467
|
+
async fetchMemberCountsByUtm(utmField, options) {
|
|
468
|
+
// Validate utm_field for defense-in-depth (even though caller validates)
|
|
469
|
+
const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
470
|
+
if (!validUtmFields.includes(utmField)) {
|
|
471
|
+
throw new errors.BadRequestError({
|
|
472
|
+
message: `Invalid UTM field: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const knex = this.knex;
|
|
477
|
+
const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
|
|
478
|
+
const {post_id: postId} = options;
|
|
479
|
+
|
|
480
|
+
// Query: Free members who haven't converted to paid within the same time window
|
|
481
|
+
const freeSignupsQuery = knex('members_created_events as mce')
|
|
482
|
+
.select(knex.raw(`mce.${utmField} as utm_value`))
|
|
483
|
+
.select(knex.raw('COUNT(DISTINCT mce.member_id) as signups'))
|
|
484
|
+
.leftJoin('members_subscription_created_events as msce', function () {
|
|
485
|
+
this.on('mce.member_id', '=', 'msce.member_id')
|
|
486
|
+
// Filter msce.created_at: only count conversions within the same time window
|
|
487
|
+
// This ensures we don't count conversions that happened outside our date range
|
|
488
|
+
.andOn('msce.created_at', '>=', knex.raw('?', [startDateTime]))
|
|
489
|
+
.andOn('msce.created_at', '<=', knex.raw('?', [endDateTime]));
|
|
490
|
+
})
|
|
491
|
+
.whereNull('msce.id')
|
|
492
|
+
.whereNotNull(`mce.${utmField}`)
|
|
493
|
+
.groupBy(`mce.${utmField}`);
|
|
494
|
+
|
|
495
|
+
// Filter mce.created_at: only include members created within the date range
|
|
496
|
+
applyDateFilter(freeSignupsQuery, startDateTime, endDateTime, 'mce.created_at');
|
|
497
|
+
|
|
498
|
+
// Apply post filtering if post_id is provided
|
|
499
|
+
if (postId) {
|
|
500
|
+
freeSignupsQuery
|
|
501
|
+
.where('mce.attribution_id', postId)
|
|
502
|
+
.where('mce.attribution_type', 'post');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const results = await freeSignupsQuery;
|
|
506
|
+
return results.map(row => ({
|
|
507
|
+
utm_value: row.utm_value,
|
|
508
|
+
signups: parseInt(row.signups) || 0
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Fetch paid conversion counts by UTM parameter with date range
|
|
514
|
+
* @param {string} utmField - The UTM field to group by
|
|
515
|
+
* @param {Object} options - Query options
|
|
516
|
+
* @returns {Promise<{utm_value: string, paid_conversions: number}[]>}
|
|
517
|
+
**/
|
|
518
|
+
async fetchPaidConversionsByUtm(utmField, options) {
|
|
519
|
+
// Validate utm_field for defense-in-depth (even though caller validates)
|
|
520
|
+
const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
521
|
+
if (!validUtmFields.includes(utmField)) {
|
|
522
|
+
throw new errors.BadRequestError({
|
|
523
|
+
message: `Invalid UTM field: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const knex = this.knex;
|
|
528
|
+
const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
|
|
529
|
+
const {post_id: postId} = options;
|
|
530
|
+
|
|
531
|
+
const paidConversionsQuery = knex('members_subscription_created_events as msce')
|
|
532
|
+
.select(knex.raw(`msce.${utmField} as utm_value`))
|
|
533
|
+
.select(knex.raw('COUNT(DISTINCT msce.member_id) as paid_conversions'))
|
|
534
|
+
.whereNotNull(`msce.${utmField}`)
|
|
535
|
+
.groupBy(`msce.${utmField}`);
|
|
536
|
+
|
|
537
|
+
// Apply date filtering
|
|
538
|
+
applyDateFilter(paidConversionsQuery, startDateTime, endDateTime, 'msce.created_at');
|
|
539
|
+
|
|
540
|
+
// Apply post filtering if post_id is provided
|
|
541
|
+
if (postId) {
|
|
542
|
+
paidConversionsQuery
|
|
543
|
+
.where('msce.attribution_id', postId)
|
|
544
|
+
.where('msce.attribution_type', 'post');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const results = await paidConversionsQuery;
|
|
548
|
+
return results.map(row => ({
|
|
549
|
+
utm_value: row.utm_value,
|
|
550
|
+
paid_conversions: parseInt(row.paid_conversions) || 0
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Fetch MRR by UTM parameter with date range
|
|
556
|
+
* @param {string} utmField - The UTM field to group by
|
|
557
|
+
* @param {Object} options - Query options
|
|
558
|
+
* @returns {Promise<{utm_value: string, mrr: number}[]>}
|
|
559
|
+
**/
|
|
560
|
+
async fetchMrrByUtm(utmField, options) {
|
|
561
|
+
// Validate utm_field for defense-in-depth (even though caller validates)
|
|
562
|
+
const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
563
|
+
if (!validUtmFields.includes(utmField)) {
|
|
564
|
+
throw new errors.BadRequestError({
|
|
565
|
+
message: `Invalid UTM field: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const knex = this.knex;
|
|
570
|
+
const {dateFrom: startDateTime, dateTo: endDateTime} = getDateBoundaries(options);
|
|
571
|
+
const {post_id: postId} = options;
|
|
572
|
+
|
|
573
|
+
// Join subscription created events with paid subscription events to get MRR changes
|
|
574
|
+
let query = knex('members_subscription_created_events as msce')
|
|
575
|
+
.join('members_paid_subscription_events as mpse', function () {
|
|
576
|
+
this.on('msce.member_id', '=', 'mpse.member_id')
|
|
577
|
+
.andOn('msce.subscription_id', '=', 'mpse.subscription_id');
|
|
578
|
+
})
|
|
579
|
+
.select(knex.raw(`msce.${utmField} as utm_value`))
|
|
580
|
+
.select(knex.raw(`SUM(mpse.mrr_delta) as mrr`))
|
|
581
|
+
.where('mpse.mrr_delta', '>', 0) // Only positive MRR changes (new subscriptions)
|
|
582
|
+
.whereNotNull(`msce.${utmField}`)
|
|
583
|
+
.groupBy(`msce.${utmField}`);
|
|
584
|
+
|
|
585
|
+
// Apply date filtering
|
|
586
|
+
applyDateFilter(query, startDateTime, endDateTime, 'msce.created_at');
|
|
587
|
+
|
|
588
|
+
// Apply post filtering if post_id is provided
|
|
589
|
+
if (postId) {
|
|
590
|
+
query
|
|
591
|
+
.where('msce.attribution_id', postId)
|
|
592
|
+
.where('msce.attribution_type', 'post');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const results = await query;
|
|
596
|
+
return results.map(row => ({
|
|
597
|
+
utm_value: row.utm_value,
|
|
598
|
+
mrr: parseInt(row.mrr) || 0
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Get UTM growth stats broken down by UTM parameter
|
|
462
604
|
* @param {Object} options
|
|
463
605
|
* @param {string} [options.utm_type='utm_source'] - Which UTM field to group by ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
|
|
464
606
|
* @param {string} [options.order='free_members desc'] - Sort order
|
|
465
607
|
* @param {number} [options.limit=50] - Maximum number of results
|
|
608
|
+
* @param {string} [options.date_from] - Start date in YYYY-MM-DD format
|
|
609
|
+
* @param {string} [options.date_to] - End date in YYYY-MM-DD format
|
|
610
|
+
* @param {string} [options.timezone] - Timezone to use for date interpretation
|
|
466
611
|
* @param {string} [options.post_id] - Optional filter by post ID
|
|
467
612
|
* @returns {Promise<{data: UtmGrowthStat[], meta: {}}>}
|
|
468
613
|
*/
|
|
@@ -470,6 +615,7 @@ class ReferrersStatsService {
|
|
|
470
615
|
const utmField = options.utm_type || 'utm_source';
|
|
471
616
|
const limit = options.limit || 50;
|
|
472
617
|
const postId = options.post_id;
|
|
618
|
+
const orderBy = options.order || 'free_members desc';
|
|
473
619
|
|
|
474
620
|
// Validate utm_type is a valid UTM field
|
|
475
621
|
const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
|
@@ -479,114 +625,94 @@ class ReferrersStatsService {
|
|
|
479
625
|
});
|
|
480
626
|
}
|
|
481
627
|
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
...item,
|
|
489
|
-
free_members: Math.floor(item.free_members * 0.3), // 30% of global
|
|
490
|
-
paid_members: Math.floor(item.paid_members * 0.25), // 25% of global
|
|
491
|
-
mrr: Math.floor(item.mrr * 0.25) // 25% of global
|
|
492
|
-
})).filter(item => item.free_members > 0 || item.paid_members > 0); // Only include items with data
|
|
493
|
-
}
|
|
628
|
+
// Fetch data from database in parallel
|
|
629
|
+
const [freeMembers, paidConversions, mrrData] = await Promise.all([
|
|
630
|
+
this.fetchMemberCountsByUtm(utmField, options),
|
|
631
|
+
this.fetchPaidConversionsByUtm(utmField, options),
|
|
632
|
+
this.fetchMrrByUtm(utmField, options)
|
|
633
|
+
]);
|
|
494
634
|
|
|
495
|
-
|
|
496
|
-
const
|
|
635
|
+
// Combine results by utm_value
|
|
636
|
+
const utmMap = new Map();
|
|
637
|
+
|
|
638
|
+
// Add free members
|
|
639
|
+
freeMembers.forEach((row) => {
|
|
640
|
+
utmMap.set(row.utm_value, {
|
|
641
|
+
utm_value: row.utm_value,
|
|
642
|
+
utm_type: utmField,
|
|
643
|
+
free_members: row.signups,
|
|
644
|
+
paid_members: 0,
|
|
645
|
+
mrr: 0
|
|
646
|
+
});
|
|
647
|
+
});
|
|
497
648
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
649
|
+
// Add paid conversions
|
|
650
|
+
paidConversions.forEach((row) => {
|
|
651
|
+
const existing = utmMap.get(row.utm_value);
|
|
652
|
+
if (existing) {
|
|
653
|
+
existing.paid_members = row.paid_conversions;
|
|
654
|
+
} else {
|
|
655
|
+
utmMap.set(row.utm_value, {
|
|
656
|
+
utm_value: row.utm_value,
|
|
657
|
+
utm_type: utmField,
|
|
658
|
+
free_members: 0,
|
|
659
|
+
paid_members: row.paid_conversions,
|
|
660
|
+
mrr: 0
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
});
|
|
503
664
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
{utm_value: 'youtube', utm_type: 'utm_source', free_members: 20, paid_members: 8, mrr: 4000},
|
|
520
|
-
{utm_value: 'instagram', utm_type: 'utm_source', free_members: 18, paid_members: 6, mrr: 3000}
|
|
521
|
-
],
|
|
522
|
-
utm_medium: [
|
|
523
|
-
{utm_value: 'organic', utm_type: 'utm_medium', free_members: 150, paid_members: 30, mrr: 15000},
|
|
524
|
-
{utm_value: 'cpc', utm_type: 'utm_medium', free_members: 90, paid_members: 20, mrr: 10000},
|
|
525
|
-
{utm_value: 'email', utm_type: 'utm_medium', free_members: 70, paid_members: 20, mrr: 10000},
|
|
526
|
-
{utm_value: 'social', utm_type: 'utm_medium', free_members: 30, paid_members: 10, mrr: 5000},
|
|
527
|
-
{utm_value: 'referral', utm_type: 'utm_medium', free_members: 25, paid_members: 8, mrr: 4000},
|
|
528
|
-
{utm_value: 'display', utm_type: 'utm_medium', free_members: 15, paid_members: 3, mrr: 1500}
|
|
529
|
-
],
|
|
530
|
-
utm_campaign: [
|
|
531
|
-
{utm_value: 'spring-sale', utm_type: 'utm_campaign', free_members: 120, paid_members: 35, mrr: 17500},
|
|
532
|
-
{utm_value: 'product-launch', utm_type: 'utm_campaign', free_members: 80, paid_members: 20, mrr: 10000},
|
|
533
|
-
{utm_value: 'webinar-series', utm_type: 'utm_campaign', free_members: 60, paid_members: 15, mrr: 7500},
|
|
534
|
-
{utm_value: 'holiday-promo', utm_type: 'utm_campaign', free_members: 45, paid_members: 18, mrr: 9000},
|
|
535
|
-
{utm_value: 'content-upgrade', utm_type: 'utm_campaign', free_members: 30, paid_members: 8, mrr: 4000},
|
|
536
|
-
{utm_value: 'partner-collab', utm_type: 'utm_campaign', free_members: 25, paid_members: 12, mrr: 6000}
|
|
537
|
-
],
|
|
538
|
-
utm_term: [
|
|
539
|
-
{utm_value: 'best-email-marketing', utm_type: 'utm_term', free_members: 85, paid_members: 22, mrr: 11000},
|
|
540
|
-
{utm_value: 'ghost-cms', utm_type: 'utm_term', free_members: 70, paid_members: 18, mrr: 9000},
|
|
541
|
-
{utm_value: 'newsletter-platform', utm_type: 'utm_term', free_members: 55, paid_members: 15, mrr: 7500},
|
|
542
|
-
{utm_value: 'content-management', utm_type: 'utm_term', free_members: 40, paid_members: 10, mrr: 5000},
|
|
543
|
-
{utm_value: 'publishing-platform', utm_type: 'utm_term', free_members: 30, paid_members: 8, mrr: 4000},
|
|
544
|
-
{utm_value: 'membership-software', utm_type: 'utm_term', free_members: 20, paid_members: 5, mrr: 2500}
|
|
545
|
-
],
|
|
546
|
-
utm_content: [
|
|
547
|
-
{utm_value: 'hero-cta', utm_type: 'utm_content', free_members: 95, paid_members: 25, mrr: 12500},
|
|
548
|
-
{utm_value: 'sidebar-banner', utm_type: 'utm_content', free_members: 75, paid_members: 18, mrr: 9000},
|
|
549
|
-
{utm_value: 'footer-link', utm_type: 'utm_content', free_members: 50, paid_members: 12, mrr: 6000},
|
|
550
|
-
{utm_value: 'email-button', utm_type: 'utm_content', free_members: 45, paid_members: 15, mrr: 7500},
|
|
551
|
-
{utm_value: 'popup-form', utm_type: 'utm_content', free_members: 35, paid_members: 8, mrr: 4000},
|
|
552
|
-
{utm_value: 'text-link', utm_type: 'utm_content', free_members: 25, paid_members: 6, mrr: 3000}
|
|
553
|
-
]
|
|
554
|
-
};
|
|
665
|
+
// Add MRR data
|
|
666
|
+
mrrData.forEach((row) => {
|
|
667
|
+
const existing = utmMap.get(row.utm_value);
|
|
668
|
+
if (existing) {
|
|
669
|
+
existing.mrr = row.mrr;
|
|
670
|
+
} else {
|
|
671
|
+
utmMap.set(row.utm_value, {
|
|
672
|
+
utm_value: row.utm_value,
|
|
673
|
+
utm_type: utmField,
|
|
674
|
+
free_members: 0,
|
|
675
|
+
paid_members: 0,
|
|
676
|
+
mrr: row.mrr
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
});
|
|
555
680
|
|
|
556
|
-
|
|
557
|
-
|
|
681
|
+
// Convert to array
|
|
682
|
+
let results = Array.from(utmMap.values());
|
|
558
683
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
* @private
|
|
562
|
-
* @param {UtmGrowthStat[]} data
|
|
563
|
-
* @param {string} [order='free_members desc']
|
|
564
|
-
* @returns {UtmGrowthStat[]}
|
|
565
|
-
*/
|
|
566
|
-
_sortUtmData(data, order = 'free_members desc') {
|
|
567
|
-
const [field, direction] = order.split(' ');
|
|
684
|
+
// Apply sorting
|
|
685
|
+
const [field, direction] = orderBy.split(' ');
|
|
568
686
|
const validFields = ['free_members', 'paid_members', 'mrr', 'utm_value'];
|
|
569
687
|
|
|
570
|
-
if (
|
|
571
|
-
|
|
688
|
+
if (validFields.includes(field)) {
|
|
689
|
+
results.sort((a, b) => {
|
|
690
|
+
let valueA = a[field];
|
|
691
|
+
let valueB = b[field];
|
|
692
|
+
|
|
693
|
+
// Handle string sorting for utm_value
|
|
694
|
+
if (field === 'utm_value') {
|
|
695
|
+
valueA = String(valueA).toLowerCase();
|
|
696
|
+
valueB = String(valueB).toLowerCase();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (direction === 'asc') {
|
|
700
|
+
return valueA < valueB ? -1 : valueA > valueB ? 1 : 0;
|
|
701
|
+
}
|
|
702
|
+
// Default to desc
|
|
703
|
+
return valueA < valueB ? 1 : valueA > valueB ? -1 : 0;
|
|
704
|
+
});
|
|
572
705
|
}
|
|
573
706
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
// Handle string sorting for utm_value
|
|
579
|
-
if (field === 'utm_value') {
|
|
580
|
-
valueA = String(valueA).toLowerCase();
|
|
581
|
-
valueB = String(valueB).toLowerCase();
|
|
582
|
-
}
|
|
707
|
+
// Apply limit (but not when filtering by post as per original implementation)
|
|
708
|
+
if (!postId && limit && limit > 0) {
|
|
709
|
+
results = results.slice(0, limit);
|
|
710
|
+
}
|
|
583
711
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
return valueA < valueB ? 1 : valueA > valueB ? -1 : 0;
|
|
589
|
-
});
|
|
712
|
+
return {
|
|
713
|
+
data: results,
|
|
714
|
+
meta: {}
|
|
715
|
+
};
|
|
590
716
|
}
|
|
591
717
|
}
|
|
592
718
|
|
|
@@ -245,15 +245,15 @@ class StatsService {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
|
-
* Get UTM growth stats broken down by UTM field
|
|
248
|
+
* Get UTM growth stats broken down by UTM field
|
|
249
249
|
* Can be filtered by post using post_id parameter
|
|
250
250
|
* @param {Object} options
|
|
251
251
|
* @param {string} [options.utm_type='utm_source'] - Which UTM field to group by ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
|
|
252
252
|
* @param {string} [options.order='free_members desc'] - Sort order
|
|
253
253
|
* @param {number} [options.limit=50] - Maximum number of results (ignored when filtering by post)
|
|
254
|
-
* @param {string} [options.date_from] - Start date in YYYY-MM-DD format
|
|
255
|
-
* @param {string} [options.date_to] - End date in YYYY-MM-DD format
|
|
256
|
-
* @param {string} [options.timezone] - Timezone to use for date interpretation
|
|
254
|
+
* @param {string} [options.date_from] - Start date in YYYY-MM-DD format
|
|
255
|
+
* @param {string} [options.date_to] - End date in YYYY-MM-DD format
|
|
256
|
+
* @param {string} [options.timezone] - Timezone to use for date interpretation
|
|
257
257
|
* @param {string} [options.post_id] - Optional filter by post ID
|
|
258
258
|
* @returns {Promise<{data: import('./ReferrersStatsService').UtmGrowthStat[], meta: {}}>}
|
|
259
259
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghost",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "The professional publishing platform",
|
|
5
5
|
"author": "Ghost Foundation",
|
|
6
6
|
"homepage": "https://ghost.org",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"@tryghost/helpers": "1.1.97",
|
|
87
87
|
"@tryghost/html-to-plaintext": "1.0.4",
|
|
88
88
|
"@tryghost/http-cache-utils": "0.1.20",
|
|
89
|
-
"@tryghost/i18n": "file:components/tryghost-i18n-6.
|
|
89
|
+
"@tryghost/i18n": "file:components/tryghost-i18n-6.4.0.tgz",
|
|
90
90
|
"@tryghost/image-transform": "1.4.6",
|
|
91
91
|
"@tryghost/job-manager": "1.0.3",
|
|
92
92
|
"@tryghost/kg-card-factory": "5.1.2",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"csso": "5.0.5",
|
|
145
145
|
"csv-writer": "1.6.0",
|
|
146
146
|
"date-fns": "2.30.0",
|
|
147
|
-
"dompurify": "3.
|
|
147
|
+
"dompurify": "3.3.0",
|
|
148
148
|
"downsize": "0.0.8",
|
|
149
149
|
"entities": "4.5.0",
|
|
150
150
|
"express": "4.21.2",
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
"heic-convert": "2.1.0",
|
|
167
167
|
"html-to-text": "5.1.1",
|
|
168
168
|
"html5parser": "2.0.2",
|
|
169
|
-
"human-number": "2.0.
|
|
169
|
+
"human-number": "2.0.7",
|
|
170
170
|
"iconv-lite": "0.6.3",
|
|
171
171
|
"image-size": "1.2.1",
|
|
172
172
|
"intl": "1.2.5",
|
|
@@ -232,7 +232,7 @@
|
|
|
232
232
|
"@types/bookshelf": "1.2.9",
|
|
233
233
|
"@types/common-tags": "1.8.4",
|
|
234
234
|
"@types/jsonwebtoken": "9.0.10",
|
|
235
|
-
"@types/node": "22.18.
|
|
235
|
+
"@types/node": "22.18.10",
|
|
236
236
|
"@types/node-jose": "1.1.13",
|
|
237
237
|
"@types/nodemailer": "6.4.20",
|
|
238
238
|
"@types/sinon": "17.0.4",
|
|
@@ -273,7 +273,7 @@
|
|
|
273
273
|
"jackspeak": "2.3.6",
|
|
274
274
|
"moment": "2.24.0",
|
|
275
275
|
"moment-timezone": "0.5.45",
|
|
276
|
-
"@tryghost/i18n": "file:components/tryghost-i18n-6.
|
|
276
|
+
"@tryghost/i18n": "file:components/tryghost-i18n-6.4.0.tgz"
|
|
277
277
|
},
|
|
278
278
|
"nx": {
|
|
279
279
|
"targets": {
|