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.
Files changed (30) hide show
  1. package/components/tryghost-i18n-6.4.0.tgz +0 -0
  2. package/core/built/admin/assets/activitypub/activitypub.js +6 -0
  3. package/core/built/admin/assets/{admin-x-activitypub/index-C8tyOPu-.mjs → activitypub/index-2Xkl6sDz.mjs} +2 -2
  4. package/core/built/admin/assets/{admin-x-activitypub/index-QqbAPyqT.mjs → activitypub/index-CKBuhv6F.mjs} +13906 -13199
  5. package/core/built/admin/assets/{chunk.524.aac61953956de04feb53.js → chunk.524.5fd100e92f0b07a59e66.js} +6 -6
  6. package/core/built/admin/assets/{chunk.582.0a1461429ddbaef85ea9.js → chunk.582.411877f4da4644562f10.js} +8 -8
  7. package/core/built/admin/assets/{ghost-1bfab97cb7f550726e894fae6650a808.js → ghost-e7b2c10bfc27fe1ef28d2697e9e7551b.js} +20 -38
  8. package/core/built/admin/assets/posts/posts.js +5529 -5511
  9. package/core/built/admin/assets/stats/stats.js +20 -20
  10. package/core/built/admin/index.html +3 -3
  11. package/core/frontend/helpers/reading_time.js +4 -4
  12. package/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js +8 -3
  13. package/core/server/data/migrations/versions/6.4/2025-10-13-10-18-38-add-tokens-otc-used-count-column.js +8 -0
  14. package/core/server/data/schema/schema.js +3 -2
  15. package/core/server/models/single-use-token.js +1 -0
  16. package/core/server/services/email-service/email-templates/template.hbs +0 -2
  17. package/core/server/services/lib/MailgunClient.js +2 -1
  18. package/core/server/services/lib/magic-link/MagicLink.js +0 -1
  19. package/core/server/services/mail/GhostMailer.js +21 -0
  20. package/core/server/services/members/SingleUseTokenProvider.js +218 -62
  21. package/core/server/services/stats/ReferrersStatsService.js +237 -111
  22. package/core/server/services/stats/StatsService.js +4 -4
  23. package/package.json +6 -6
  24. package/tsconfig.tsbuildinfo +1 -1
  25. package/yarn.lock +381 -243
  26. package/components/tryghost-i18n-6.3.0.tgz +0 -0
  27. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +0 -6
  28. package/core/server/services/lib/magic-link/JWTTokenProvider.js +0 -52
  29. package/core/server/services/lib/magic-link/README.md +0 -60
  30. /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
- * Get UTM growth stats broken down by UTM parameter (fixture data for now)
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
- // Fixture data; will replace with real data once members service is wired up fully
483
- let fixtureData = this._getUtmFixtureData(utmField);
484
-
485
- // If filtering by post, scale down the data
486
- if (postId) {
487
- fixtureData = fixtureData.map(item => ({
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
- const sortedData = this._sortUtmData(fixtureData, options.order);
496
- const limitedData = postId ? sortedData : (limit > 0 ? sortedData.slice(0, limit) : sortedData);
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
- return {
499
- data: limitedData,
500
- meta: {}
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
- * Generate fixture data for UTM parameters
506
- * @private
507
- * @param {string} utmField - The UTM field ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
508
- * @returns {UtmGrowthStat[]}
509
- */
510
- _getUtmFixtureData(utmField) {
511
- const fixtures = {
512
- utm_source: [
513
- {utm_value: 'google', utm_type: 'utm_source', free_members: 100, paid_members: 20, mrr: 10000},
514
- {utm_value: 'facebook', utm_type: 'utm_source', free_members: 80, paid_members: 15, mrr: 7500},
515
- {utm_value: 'twitter', utm_type: 'utm_source', free_members: 60, paid_members: 10, mrr: 5000},
516
- {utm_value: 'newsletter', utm_type: 'utm_source', free_members: 40, paid_members: 25, mrr: 12500},
517
- {utm_value: 'linkedin', utm_type: 'utm_source', free_members: 35, paid_members: 12, mrr: 6000},
518
- {utm_value: 'reddit', utm_type: 'utm_source', free_members: 25, paid_members: 5, mrr: 2500},
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
- return fixtures[utmField] || fixtures.utm_source;
557
- }
681
+ // Convert to array
682
+ let results = Array.from(utmMap.values());
558
683
 
559
- /**
560
- * Sort UTM data by the specified order
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 (!validFields.includes(field)) {
571
- return data;
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
- return [...data].sort((a, b) => {
575
- let valueA = a[field];
576
- let valueB = b[field];
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
- if (direction === 'asc') {
585
- return valueA < valueB ? -1 : valueA > valueB ? 1 : 0;
586
- }
587
- // Default to desc
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 (fixture data)
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 (not yet implemented for fixtures)
255
- * @param {string} [options.date_to] - End date in YYYY-MM-DD format (not yet implemented for fixtures)
256
- * @param {string} [options.timezone] - Timezone to use for date interpretation (not yet implemented for fixtures)
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.0",
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.3.0.tgz",
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.2.7",
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.6",
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.8",
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.3.0.tgz"
276
+ "@tryghost/i18n": "file:components/tryghost-i18n-6.4.0.tgz"
277
277
  },
278
278
  "nx": {
279
279
  "targets": {