ghost 6.1.0 → 6.3.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 (69) hide show
  1. package/components/tryghost-i18n-6.3.0.tgz +0 -0
  2. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +2 -2
  3. package/core/built/admin/assets/admin-x-activitypub/{index-DmCoswaX.mjs → index-C8tyOPu-.mjs} +2 -2
  4. package/core/built/admin/assets/admin-x-activitypub/{index-lT95Q15h.mjs → index-QqbAPyqT.mjs} +77 -76
  5. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-Bu9qXr9c.mjs → CodeEditorView-CHa5Y-LX.mjs} +3 -3
  6. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  7. package/core/built/admin/assets/admin-x-settings/{index-o4Q9MNrB.mjs → index-8WxO2QXI.mjs} +3017 -2827
  8. package/core/built/admin/assets/admin-x-settings/{index-qEdfz2hd.mjs → index-CGFCkAXn.mjs} +10 -6
  9. package/core/built/admin/assets/admin-x-settings/{index-BEpRBH9g.mjs → index-Cg4zMcj4.mjs} +2 -2
  10. package/core/built/admin/assets/admin-x-settings/{index-BgCSf8S1.mjs → index-DD3HKlR3.mjs} +306 -315
  11. package/core/built/admin/assets/admin-x-settings/{modals-BtQORnS4.mjs → modals-DH5H9Tgk.mjs} +8801 -8807
  12. package/core/built/admin/assets/{chunk.397.e5d027e53a68dff31d76.js → chunk.397.a720333cfffc99c47e71.js} +5 -4
  13. package/core/built/admin/assets/{chunk.524.2aa0847042f20c9a2a00.js → chunk.524.aac61953956de04feb53.js} +6 -6
  14. package/core/built/admin/assets/{chunk.582.9182c19afab95991771e.js → chunk.582.0a1461429ddbaef85ea9.js} +7 -7
  15. package/core/built/admin/assets/{ghost-9c47d152972b304cab0fb982dc3fccc1.js → ghost-1bfab97cb7f550726e894fae6650a808.js} +24 -22
  16. package/core/built/admin/assets/ghost-8ade80412a20088a4f0a9a1159f0bdba.css +1 -0
  17. package/core/built/admin/assets/ghost-dark-b128f29fc44b34b6cfb0fc8492266c2a.css +1 -0
  18. package/core/built/admin/assets/posts/posts.js +30617 -30330
  19. package/core/built/admin/assets/stats/stats.js +21342 -21272
  20. package/core/built/admin/index.html +5 -5
  21. package/core/frontend/helpers/ghost_head.js +2 -1
  22. package/core/server/api/endpoints/stats.js +37 -1
  23. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +1 -0
  24. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +1 -0
  25. package/core/server/data/migrations/utils/schema.js +11 -6
  26. package/core/server/data/migrations/versions/6.2/2025-09-30-14-28-09-add-utm-fields.js +24 -0
  27. package/core/server/data/migrations/versions/6.3/2025-10-02-15-13-31-add-members-otc-secret-setting.js +9 -0
  28. package/core/server/data/schema/commands.js +21 -6
  29. package/core/server/data/schema/default-settings/default-settings.json +4 -0
  30. package/core/server/data/schema/schema.js +24 -0
  31. package/core/server/models/settings.js +1 -0
  32. package/core/server/services/donations/DonationBookshelfRepository.js +6 -1
  33. package/core/server/services/donations/DonationBookshelfRepository.ts +11 -1
  34. package/core/server/services/donations/DonationPaymentEvent.js +10 -0
  35. package/core/server/services/donations/DonationPaymentEvent.ts +10 -0
  36. package/core/server/services/email-service/EmailRenderer.js +1 -1
  37. package/core/server/services/lib/MailgunClient.js +4 -3
  38. package/core/server/services/lib/magic-link/MagicLink.js +9 -9
  39. package/core/server/services/mail/GhostMailer.js +4 -1
  40. package/core/server/services/member-attribution/AttributionBuilder.js +55 -10
  41. package/core/server/services/member-attribution/README.md +101 -0
  42. package/core/server/services/member-attribution/ReferrerTranslator.js +40 -3
  43. package/core/server/services/member-attribution/UrlHistory.js +5 -0
  44. package/core/server/services/members/MembersConfigProvider.js +0 -15
  45. package/core/server/services/members/SingleUseTokenProvider.js +8 -8
  46. package/core/server/services/members/api.js +1 -1
  47. package/core/server/services/members/members-api/controllers/RouterController.js +26 -0
  48. package/core/server/services/members/members-api/repositories/MemberRepository.js +6 -1
  49. package/core/server/services/members-events/EventStorage.js +10 -0
  50. package/core/server/services/stats/ReferrersStatsService.js +143 -0
  51. package/core/server/services/stats/StatsService.js +17 -0
  52. package/core/server/services/stripe/StripeAPI.js +7 -2
  53. package/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js +6 -1
  54. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  55. package/core/server/web/members/app.js +2 -0
  56. package/core/server/web/shared/middleware/api/spam-prevention.js +76 -0
  57. package/core/server/web/shared/middleware/brute.js +23 -0
  58. package/core/shared/config/defaults.json +13 -1
  59. package/core/shared/config/env/config.testing-browser.json +12 -0
  60. package/core/shared/config/env/config.testing-mysql.json +12 -0
  61. package/core/shared/config/env/config.testing.json +12 -0
  62. package/core/shared/labs.js +1 -0
  63. package/package.json +8 -8
  64. package/tsconfig.tsbuildinfo +1 -1
  65. package/yarn.lock +288 -292
  66. package/components/tryghost-i18n-6.1.0.tgz +0 -0
  67. package/core/built/admin/assets/ghost-791574a9e2efe65c88412947d2e80170.css +0 -1
  68. package/core/built/admin/assets/ghost-dark-1a7d101d525c0fdcf406ac0abd98540f.css +0 -1
  69. /package/core/built/admin/assets/{chunk.397.e5d027e53a68dff31d76.js.LICENSE.txt → chunk.397.a720333cfffc99c47e71.js.LICENSE.txt} +0 -0
@@ -7,6 +7,11 @@
7
7
  * @prop {string|null} referrerSource
8
8
  * @prop {string|null} referrerMedium
9
9
  * @prop {string|null} referrerUrl
10
+ * @prop {string|null} utmSource
11
+ * @prop {string|null} utmMedium
12
+ * @prop {string|null} utmCampaign
13
+ * @prop {string|null} utmTerm
14
+ * @prop {string|null} utmContent
10
15
  */
11
16
 
12
17
  class Attribution {
@@ -21,9 +26,14 @@ class Attribution {
21
26
  * @param {string|null} [data.referrerSource]
22
27
  * @param {string|null} [data.referrerMedium]
23
28
  * @param {string|null} [data.referrerUrl]
29
+ * @param {string|null} [data.utmSource]
30
+ * @param {string|null} [data.utmMedium]
31
+ * @param {string|null} [data.utmCampaign]
32
+ * @param {string|null} [data.utmTerm]
33
+ * @param {string|null} [data.utmContent]
24
34
  */
25
35
  constructor({
26
- id, url, type, referrerSource, referrerMedium, referrerUrl
36
+ id, url, type, referrerSource, referrerMedium, referrerUrl, utmSource, utmMedium, utmCampaign, utmTerm, utmContent
27
37
  }, {urlTranslator}) {
28
38
  this.id = id;
29
39
  this.url = url;
@@ -31,6 +41,11 @@ class Attribution {
31
41
  this.referrerSource = referrerSource;
32
42
  this.referrerMedium = referrerMedium;
33
43
  this.referrerUrl = referrerUrl;
44
+ this.utmSource = utmSource;
45
+ this.utmMedium = utmMedium;
46
+ this.utmCampaign = utmCampaign;
47
+ this.utmTerm = utmTerm;
48
+ this.utmContent = utmContent;
34
49
 
35
50
  /**
36
51
  * @private
@@ -57,7 +72,12 @@ class Attribution {
57
72
  title: null,
58
73
  referrerSource: this.referrerSource,
59
74
  referrerMedium: this.referrerMedium,
60
- referrerUrl: this.referrerUrl
75
+ referrerUrl: this.referrerUrl,
76
+ utmSource: this.utmSource,
77
+ utmMedium: this.utmMedium,
78
+ utmCampaign: this.utmCampaign,
79
+ utmTerm: this.utmTerm,
80
+ utmContent: this.utmContent
61
81
  };
62
82
  }
63
83
  return {
@@ -67,7 +87,12 @@ class Attribution {
67
87
  title: this.#urlTranslator.getUrlTitle(this.url),
68
88
  referrerSource: this.referrerSource,
69
89
  referrerMedium: this.referrerMedium,
70
- referrerUrl: this.referrerUrl
90
+ referrerUrl: this.referrerUrl,
91
+ utmSource: this.utmSource,
92
+ utmMedium: this.utmMedium,
93
+ utmCampaign: this.utmCampaign,
94
+ utmTerm: this.utmTerm,
95
+ utmContent: this.utmContent
71
96
  };
72
97
  }
73
98
 
@@ -80,7 +105,12 @@ class Attribution {
80
105
  title: model.get('title') ?? model.get('name') ?? this.#urlTranslator.getUrlTitle(this.url),
81
106
  referrerSource: this.referrerSource,
82
107
  referrerMedium: this.referrerMedium,
83
- referrerUrl: this.referrerUrl
108
+ referrerUrl: this.referrerUrl,
109
+ utmSource: this.utmSource,
110
+ utmMedium: this.utmMedium,
111
+ utmCampaign: this.utmCampaign,
112
+ utmTerm: this.utmTerm,
113
+ utmContent: this.utmContent
84
114
  };
85
115
  }
86
116
 
@@ -118,14 +148,19 @@ class AttributionBuilder {
118
148
  /**
119
149
  * Creates an Attribution object with the dependencies injected
120
150
  */
121
- build({id, url, type, referrerSource, referrerMedium, referrerUrl}) {
151
+ build({id, url, type, referrerSource, referrerMedium, referrerUrl, utmSource, utmMedium, utmCampaign, utmTerm, utmContent}) {
122
152
  return new Attribution({
123
153
  id,
124
154
  url,
125
155
  type,
126
156
  referrerSource,
127
157
  referrerMedium,
128
- referrerUrl
158
+ referrerUrl,
159
+ utmSource,
160
+ utmMedium,
161
+ utmCampaign,
162
+ utmTerm,
163
+ utmContent
129
164
  }, {urlTranslator: this.urlTranslator});
130
165
  }
131
166
 
@@ -142,14 +177,24 @@ class AttributionBuilder {
142
177
  type: null,
143
178
  referrerSource: null,
144
179
  referrerMedium: null,
145
- referrerUrl: null
180
+ referrerUrl: null,
181
+ utmSource: null,
182
+ utmMedium: null,
183
+ utmCampaign: null,
184
+ utmTerm: null,
185
+ utmContent: null
146
186
  });
147
187
  }
148
188
 
149
189
  const referrerData = this.referrerTranslator.getReferrerDetails(history) || {
150
190
  referrerSource: null,
151
191
  referrerMedium: null,
152
- referrerUrl: null
192
+ referrerUrl: null,
193
+ utmSource: null,
194
+ utmMedium: null,
195
+ utmCampaign: null,
196
+ utmTerm: null,
197
+ utmContent: null
153
198
  };
154
199
 
155
200
  // Start at the end. Return the first post we find
@@ -194,10 +239,10 @@ class AttributionBuilder {
194
239
 
195
240
  // We only have history items without a path that have invalid ids
196
241
  return this.build({
197
- ...referrerData,
198
242
  id: null,
199
243
  url: null,
200
- type: null
244
+ type: null,
245
+ ...referrerData
201
246
  });
202
247
  }
203
248
  }
@@ -0,0 +1,101 @@
1
+ # Member Attribution Service
2
+
3
+ The Member Attribution Service tracks how members discover and sign up to a Ghost site. It captures attribution data (source pages, referrer information, UTM parameters) and associates it with member signup and subscription events.
4
+
5
+ ## Features
6
+
7
+ ### Core Attribution Tracking
8
+ - **Page Attribution**: Tracks which pages (posts, pages, authors, tags) visitors viewed before becoming members
9
+ - **Referrer Attribution**: Identifies external sources (search engines, social media, direct links) that brought visitors to the site
10
+ - **UTM Parameter Tracking**: Captures UTM campaign parameters (source, medium, campaign, term, content) for marketing attribution
11
+ - **Last Post Algorithm**: Prioritizes the last post viewed in the visitor's journey as the primary attribution source
12
+
13
+ ### Attribution Sources
14
+ - **Content Attribution**: Posts, pages, authors, and tags visited by members
15
+ - **External Referrers**: Tracks referrer sources like Google, Facebook, Twitter, etc. using `@tryghost/referrer-parser`
16
+ - **Manual Creation**: Tracks members created via Admin UI, API, or import tools
17
+ - **Integration Attribution**: Associates members created via integrations with the integration name
18
+ - **Newsletter Links**: Adds attribution tracking to outbound links in newsletters with `?ref=` parameters
19
+
20
+ ### Settings
21
+ - **Member Source Tracking**: Can be enabled/disabled via `members_track_sources` setting
22
+ - **Outbound Link Tagging**: Can be enabled/disabled via `outbound_link_tagging` setting
23
+
24
+ ## Architecture
25
+
26
+ ### Component Overview
27
+
28
+ ```mermaid
29
+ graph TD
30
+ A[Frontend Browser<br/>member-attribution.js<br/>Captures URL history in session] -->|URLHistory Array| B[MemberAttributionService<br/>Main service interface<br/>Coordinates attribution logic]
31
+
32
+ B --> C[AttributionBuilder<br/>Converts URLHistory into Attribution objects<br/>Implements Last Post Algorithm]
33
+ B --> D[UrlTranslator<br/>Converts paths to resource IDs and types<br/>Fetches Post/Page/Tag/Author models]
34
+ B --> E[ReferrerTranslator<br/>Parses referrer URLs to identify sources<br/>Extracts UTM parameters]
35
+ B --> F[OutboundLinkTagger<br/>Adds ?ref parameters to external links]
36
+
37
+ C --> D
38
+ C --> E
39
+ ```
40
+
41
+ ### Components
42
+
43
+ #### 1. **MemberAttributionService** (`MemberAttributionService.js`)
44
+ Main service interface that coordinates all attribution logic.
45
+
46
+ #### 2. **AttributionBuilder** (`AttributionBuilder.js`)
47
+ Converts URL history into attribution resources using the "Last Post Algorithm™️".
48
+
49
+ **Key Classes:**
50
+ - `Attribution`: Represents attribution data with methods to fetch and enrich resources
51
+ - `AttributionBuilder`: Factory for creating `Attribution` instances
52
+
53
+ #### 3. **UrlHistory** (`UrlHistory.js`)
54
+ Validated container for URL history arrays from the frontend.
55
+
56
+ #### 4. **UrlTranslator** (`UrlTranslator.js`)
57
+ Translates between URLs and Ghost resources.
58
+
59
+ #### 5. **ReferrerTranslator** (`ReferrerTranslator.js`)
60
+ Parses referrer information into source and medium classifications.
61
+
62
+ #### 6. **OutboundLinkTagger** (`OutboundLinkTagger.js`)
63
+ Adds `?ref=` parameters to external links in newsletters.
64
+
65
+ #### 7. **Frontend Script** (`member-attribution.js`)
66
+ Browser-side script that captures visitor journey in sessionStorage.
67
+
68
+ ## Attribution Types
69
+
70
+ The service supports these attribution types:
71
+
72
+ | Type | Description | Has ID | Resource Model |
73
+ |----------|------------------------------------------|--------|----------------|
74
+ | `post` | Blog post | ✓ | Post |
75
+ | `page` | Static page | ✓ | Post |
76
+ | `author` | Author page | ✓ | User |
77
+ | `tag` | Tag page | ✓ | Tag |
78
+ | `url` | Generic URL (no specific resource) | ✗ | None |
79
+ | `null` | No attribution (tracking disabled/empty) | ✗ | None |
80
+
81
+ ## Internal Context Sources
82
+
83
+ When members are created through Ghost's internal systems:
84
+
85
+ | Context | referrerSource | referrerMedium |
86
+ |--------------|-----------------------|-----------------|
87
+ | `import` | Imported | Member Importer |
88
+ | `admin` | Created manually | Ghost Admin |
89
+ | `api` | Created via API | Admin API |
90
+ | `integration`| Integration: {name} | Admin API |
91
+
92
+ ## Testing
93
+
94
+ Tests are located in:
95
+ - `test/unit/server/services/member-attribution/attribution.test.js`
96
+ - `test/unit/server/services/member-attribution/history.test.js`
97
+ - `test/unit/server/services/member-attribution/service.test.js`
98
+ - `test/unit/server/services/member-attribution/url-translator.test.js`
99
+ - `test/unit/server/services/member-attribution/referrer-translator.test.js`
100
+ - `test/unit/server/services/member-attribution/outbound-link-tagger.test.js`
101
+ - `test/e2e-server/services/member-attribution.test.js`
@@ -3,6 +3,11 @@
3
3
  * @prop {string|null} [referrerSource]
4
4
  * @prop {string|null} [referrerMedium]
5
5
  * @prop {string|null} [referrerUrl]
6
+ * @prop {string|null} [utmSource]
7
+ * @prop {string|null} [utmMedium]
8
+ * @prop {string|null} [utmCampaign]
9
+ * @prop {string|null} [utmTerm]
10
+ * @prop {string|null} [utmContent]
6
11
  */
7
12
 
8
13
  const {ReferrerParser} = require('@tryghost/referrer-parser');
@@ -36,10 +41,40 @@ class ReferrerTranslator {
36
41
  return {
37
42
  referrerSource: null,
38
43
  referrerMedium: null,
39
- referrerUrl: null
44
+ referrerUrl: null,
45
+ utmSource: null,
46
+ utmMedium: null,
47
+ utmCampaign: null,
48
+ utmTerm: null,
49
+ utmContent: null
40
50
  };
41
51
  }
42
52
 
53
+ // Look for UTM parameters (earliest entry with UTM data)
54
+ // Note: history is ordered newest-to-oldest, so we want the LAST match
55
+ // This captures the original campaign source rather than subsequent navigations
56
+ let utmData = {
57
+ utmSource: null,
58
+ utmMedium: null,
59
+ utmCampaign: null,
60
+ utmTerm: null,
61
+ utmContent: null
62
+ };
63
+
64
+ // In finding the 'campaign' that got the user here, we want the earliest entry with UTM data
65
+ for (const item of history) {
66
+ if (item.utmSource || item.utmMedium || item.utmCampaign || item.utmTerm || item.utmContent) {
67
+ utmData = {
68
+ utmSource: item.utmSource || null,
69
+ utmMedium: item.utmMedium || null,
70
+ utmCampaign: item.utmCampaign || null,
71
+ utmTerm: item.utmTerm || null,
72
+ utmContent: item.utmContent || null
73
+ };
74
+ }
75
+ }
76
+
77
+ // In finding the 'content' that got the user to sign up, we want the latest entry with referrer data
43
78
  for (const item of history) {
44
79
  let refUrl = this.getUrlFromStr(item.referrerUrl);
45
80
  if (refUrl?.hostname === 'checkout.stripe.com') {
@@ -53,7 +88,8 @@ class ReferrerTranslator {
53
88
  return {
54
89
  referrerSource,
55
90
  referrerMedium,
56
- referrerUrl
91
+ referrerUrl,
92
+ ...utmData
57
93
  };
58
94
  }
59
95
  }
@@ -61,7 +97,8 @@ class ReferrerTranslator {
61
97
  return {
62
98
  referrerSource: 'Direct',
63
99
  referrerMedium: null,
64
- referrerUrl: null
100
+ referrerUrl: null,
101
+ ...utmData
65
102
  };
66
103
  }
67
104
 
@@ -6,6 +6,11 @@
6
6
  * @prop {string} [referrerSource]
7
7
  * @prop {string} [referrerMedium]
8
8
  * @prop {string} [referrerUrl]
9
+ * @prop {string} [utmSource]
10
+ * @prop {string} [utmMedium]
11
+ * @prop {string} [utmCampaign]
12
+ * @prop {string} [utmTerm]
13
+ * @prop {string} [utmContent]
9
14
  * @prop {number} time
10
15
  */
11
16
 
@@ -1,6 +1,5 @@
1
1
  const logging = require('@tryghost/logging');
2
2
  const {URL} = require('url');
3
- const crypto = require('crypto');
4
3
  const createKeypair = require('keypair');
5
4
 
6
5
  class MembersConfigProvider {
@@ -42,20 +41,6 @@ class MembersConfigProvider {
42
41
  return this._settingsHelpers.isStripeConnected();
43
42
  }
44
43
 
45
- getAuthSecret() {
46
- const hexSecret = this._settingsCache.get('members_email_auth_secret');
47
- if (!hexSecret) {
48
- logging.warn('Could not find members_email_auth_secret, using dynamically generated secret');
49
- return crypto.randomBytes(64);
50
- }
51
- const secret = Buffer.from(hexSecret, 'hex');
52
- if (secret.length < 64) {
53
- logging.warn('members_email_auth_secret not large enough (64 bytes), using dynamically generated secret');
54
- return crypto.randomBytes(64);
55
- }
56
- return secret;
57
- }
58
-
59
44
  getAllowSelfSignup() {
60
45
  // Free signups are allowed only if the site subscription is set to "Full-access"
61
46
  // It is blocked for "Invite-only", "Paid-members-only" and "None" accesses
@@ -193,7 +193,7 @@ class SingleUseTokenProvider {
193
193
  }
194
194
 
195
195
  try {
196
- const model = await this.model.findOne({id: otcRef});
196
+ const model = await this.model.findOne({uuid: otcRef});
197
197
 
198
198
  if (!model) {
199
199
  return false;
@@ -208,16 +208,16 @@ class SingleUseTokenProvider {
208
208
  }
209
209
 
210
210
  /**
211
- * @method getIdByToken
212
- * Retrieves the ID associated with a given token.
211
+ * @method getRefByToken
212
+ * Retrieves the ref associated with a given token.
213
213
  *
214
214
  * @param {string} token - The token to look up.
215
- * @returns {Promise<string|null>} The ID if found, or null if not found or on error.
215
+ * @returns {Promise<string|null>} The ref if found, or null if not found or on error.
216
216
  */
217
- async getIdByToken(token) {
217
+ async getRefByToken(token) {
218
218
  try {
219
219
  const model = await this.model.findOne({token});
220
- return model ? model.get('id') : null;
220
+ return model ? model.get('uuid') : null;
221
221
  } catch (err) {
222
222
  return null;
223
223
  }
@@ -232,7 +232,7 @@ class SingleUseTokenProvider {
232
232
  */
233
233
  async getTokenByRef(ref) {
234
234
  try {
235
- const model = await this.model.findOne({id: ref});
235
+ const model = await this.model.findOne({uuid: ref});
236
236
  return model ? model.get('token') : null;
237
237
  } catch (err) {
238
238
  return null;
@@ -300,7 +300,7 @@ class SingleUseTokenProvider {
300
300
  return false;
301
301
  }
302
302
 
303
- const tokenId = await this.getIdByToken(token);
303
+ const tokenId = await this.getRefByToken(token);
304
304
  if (!tokenId) {
305
305
  return false;
306
306
  }
@@ -58,7 +58,7 @@ function createApiInstance(config) {
58
58
  validityPeriod: MAGIC_LINK_TOKEN_VALIDITY,
59
59
  validityPeriodAfterUsage: MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE,
60
60
  maxUsageCount: MAGIC_LINK_TOKEN_MAX_USAGE_COUNT,
61
- secret: settingsCache.get('members_email_auth_secret')
61
+ secret: settingsCache.get('members_otc_secret')
62
62
  })
63
63
  },
64
64
  mail: {
@@ -203,6 +203,11 @@ module.exports = class RouterController {
203
203
  delete metadata.referrer_source;
204
204
  delete metadata.referrer_medium;
205
205
  delete metadata.referrer_url;
206
+ delete metadata.utm_source;
207
+ delete metadata.utm_medium;
208
+ delete metadata.utm_campaign;
209
+ delete metadata.utm_term;
210
+ delete metadata.utm_content;
206
211
 
207
212
  if (metadata.urlHistory) {
208
213
  // The full attribution history doesn't fit in the Stripe metadata (can't store objects + limited to 50 keys and 500 chars values)
@@ -236,6 +241,27 @@ module.exports = class RouterController {
236
241
  if (attribution.referrerUrl) {
237
242
  metadata.referrer_url = attribution.referrerUrl;
238
243
  }
244
+
245
+ // UTM parameters
246
+ if (attribution.utmSource) {
247
+ metadata.utm_source = attribution.utmSource;
248
+ }
249
+
250
+ if (attribution.utmMedium) {
251
+ metadata.utm_medium = attribution.utmMedium;
252
+ }
253
+
254
+ if (attribution.utmCampaign) {
255
+ metadata.utm_campaign = attribution.utmCampaign;
256
+ }
257
+
258
+ if (attribution.utmTerm) {
259
+ metadata.utm_term = attribution.utmTerm;
260
+ }
261
+
262
+ if (attribution.utmContent) {
263
+ metadata.utm_content = attribution.utmContent;
264
+ }
239
265
  }
240
266
  }
241
267
 
@@ -1179,7 +1179,12 @@ module.exports = class MemberRepository {
1179
1179
  type: data.attribution?.type ?? stripeSubscriptionData.metadata?.attribution_type ?? null,
1180
1180
  referrerSource: data.attribution?.referrerSource ?? stripeSubscriptionData.metadata?.referrer_source ?? null,
1181
1181
  referrerMedium: data.attribution?.referrerMedium ?? stripeSubscriptionData.metadata?.referrer_medium ?? null,
1182
- referrerUrl: data.attribution?.referrerUrl ?? stripeSubscriptionData.metadata?.referrer_url ?? null
1182
+ referrerUrl: data.attribution?.referrerUrl ?? stripeSubscriptionData.metadata?.referrer_url ?? null,
1183
+ utmSource: data.attribution?.utmSource ?? stripeSubscriptionData.metadata?.utm_source ?? null,
1184
+ utmMedium: data.attribution?.utmMedium ?? stripeSubscriptionData.metadata?.utm_medium ?? null,
1185
+ utmCampaign: data.attribution?.utmCampaign ?? stripeSubscriptionData.metadata?.utm_campaign ?? null,
1186
+ utmTerm: data.attribution?.utmTerm ?? stripeSubscriptionData.metadata?.utm_term ?? null,
1187
+ utmContent: data.attribution?.utmContent ?? stripeSubscriptionData.metadata?.utm_content ?? null
1183
1188
  };
1184
1189
 
1185
1190
  const subscriptionCreatedEvent = SubscriptionCreatedEvent.create({
@@ -35,6 +35,11 @@ class EventStorage {
35
35
  referrer_source: attribution?.referrerSource ?? null,
36
36
  referrer_medium: attribution?.referrerMedium ?? null,
37
37
  referrer_url: attribution?.referrerUrl ?? null,
38
+ utm_source: attribution?.utmSource ?? null,
39
+ utm_medium: attribution?.utmMedium ?? null,
40
+ utm_campaign: attribution?.utmCampaign ?? null,
41
+ utm_term: attribution?.utmTerm ?? null,
42
+ utm_content: attribution?.utmContent ?? null,
38
43
  batch_id: event.data.batchId ?? null
39
44
  });
40
45
  });
@@ -52,6 +57,11 @@ class EventStorage {
52
57
  referrer_source: attribution?.referrerSource ?? null,
53
58
  referrer_medium: attribution?.referrerMedium ?? null,
54
59
  referrer_url: attribution?.referrerUrl ?? null,
60
+ utm_source: attribution?.utmSource ?? null,
61
+ utm_medium: attribution?.utmMedium ?? null,
62
+ utm_campaign: attribution?.utmCampaign ?? null,
63
+ utm_term: attribution?.utmTerm ?? null,
64
+ utm_content: attribution?.utmContent ?? null,
55
65
  batch_id: event.data.batchId ?? null
56
66
  });
57
67
  });
@@ -1,4 +1,5 @@
1
1
  const moment = require('moment');
2
+ const errors = require('@tryghost/errors');
2
3
 
3
4
  // Import centralized date utilities
4
5
  const {getDateBoundaries, applyDateFilter} = require('./utils/date-utils');
@@ -455,6 +456,138 @@ class ReferrersStatsService {
455
456
  meta: {}
456
457
  };
457
458
  }
459
+
460
+ /**
461
+ * Get UTM growth stats broken down by UTM parameter (fixture data for now)
462
+ * @param {Object} options
463
+ * @param {string} [options.utm_type='utm_source'] - Which UTM field to group by ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
464
+ * @param {string} [options.order='free_members desc'] - Sort order
465
+ * @param {number} [options.limit=50] - Maximum number of results
466
+ * @param {string} [options.post_id] - Optional filter by post ID
467
+ * @returns {Promise<{data: UtmGrowthStat[], meta: {}}>}
468
+ */
469
+ async getUtmGrowthStats(options = {}) {
470
+ const utmField = options.utm_type || 'utm_source';
471
+ const limit = options.limit || 50;
472
+ const postId = options.post_id;
473
+
474
+ // Validate utm_type is a valid UTM field
475
+ const validUtmFields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
476
+ if (!validUtmFields.includes(utmField)) {
477
+ throw new errors.BadRequestError({
478
+ message: `Invalid utm_type: ${utmField}. Must be one of: ${validUtmFields.join(', ')}`
479
+ });
480
+ }
481
+
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
+ }
494
+
495
+ const sortedData = this._sortUtmData(fixtureData, options.order);
496
+ const limitedData = postId ? sortedData : (limit > 0 ? sortedData.slice(0, limit) : sortedData);
497
+
498
+ return {
499
+ data: limitedData,
500
+ meta: {}
501
+ };
502
+ }
503
+
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
+ };
555
+
556
+ return fixtures[utmField] || fixtures.utm_source;
557
+ }
558
+
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(' ');
568
+ const validFields = ['free_members', 'paid_members', 'mrr', 'utm_value'];
569
+
570
+ if (!validFields.includes(field)) {
571
+ return data;
572
+ }
573
+
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
+ }
583
+
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
+ });
590
+ }
458
591
  }
459
592
 
460
593
  module.exports = ReferrersStatsService;
@@ -507,3 +640,13 @@ module.exports.normalizeSource = normalizeSource;
507
640
  * @property {number} mrr Total MRR from this source (in cents)
508
641
  * @property {string} date The date (YYYY-MM-DD) on which these counts were recorded
509
642
  **/
643
+
644
+ /**
645
+ * @typedef {object} UtmGrowthStat
646
+ * @type {Object}
647
+ * @property {string} utm_value - The UTM parameter value (e.g., 'google', 'facebook')
648
+ * @property {string} utm_type - The UTM parameter type ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content')
649
+ * @property {number} free_members - Count of free member signups
650
+ * @property {number} paid_members - Count of paid member conversions
651
+ * @property {number} mrr - Total MRR from this UTM parameter (in cents)
652
+ **/