ghost 5.111.0 → 5.113.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 (167) hide show
  1. package/components/{tryghost-adapter-cache-redis-5.111.0.tgz → tryghost-adapter-cache-redis-5.113.0.tgz} +0 -0
  2. package/components/{tryghost-adapter-manager-5.111.0.tgz → tryghost-adapter-manager-5.113.0.tgz} +0 -0
  3. package/components/tryghost-announcement-bar-settings-5.113.0.tgz +0 -0
  4. package/components/{tryghost-api-framework-5.111.0.tgz → tryghost-api-framework-5.113.0.tgz} +0 -0
  5. package/components/{tryghost-api-version-compatibility-service-5.111.0.tgz → tryghost-api-version-compatibility-service-5.113.0.tgz} +0 -0
  6. package/components/{tryghost-audience-feedback-5.111.0.tgz → tryghost-audience-feedback-5.113.0.tgz} +0 -0
  7. package/components/tryghost-bookshelf-repository-5.113.0.tgz +0 -0
  8. package/components/{tryghost-bootstrap-socket-5.111.0.tgz → tryghost-bootstrap-socket-5.113.0.tgz} +0 -0
  9. package/components/tryghost-captcha-service-5.113.0.tgz +0 -0
  10. package/components/tryghost-constants-5.113.0.tgz +0 -0
  11. package/components/tryghost-custom-fonts-5.113.0.tgz +0 -0
  12. package/components/tryghost-custom-theme-settings-service-5.113.0.tgz +0 -0
  13. package/components/{tryghost-data-generator-5.111.0.tgz → tryghost-data-generator-5.113.0.tgz} +0 -0
  14. package/components/tryghost-domain-events-5.113.0.tgz +0 -0
  15. package/components/tryghost-donations-5.113.0.tgz +0 -0
  16. package/components/tryghost-email-addresses-5.113.0.tgz +0 -0
  17. package/components/{tryghost-email-analytics-provider-mailgun-5.111.0.tgz → tryghost-email-analytics-provider-mailgun-5.113.0.tgz} +0 -0
  18. package/components/tryghost-email-analytics-service-5.113.0.tgz +0 -0
  19. package/components/{tryghost-email-content-generator-5.111.0.tgz → tryghost-email-content-generator-5.113.0.tgz} +0 -0
  20. package/components/tryghost-email-events-5.113.0.tgz +0 -0
  21. package/components/tryghost-email-service-5.113.0.tgz +0 -0
  22. package/components/{tryghost-email-suppression-list-5.111.0.tgz → tryghost-email-suppression-list-5.113.0.tgz} +0 -0
  23. package/components/{tryghost-express-dynamic-redirects-5.111.0.tgz → tryghost-express-dynamic-redirects-5.113.0.tgz} +0 -0
  24. package/components/tryghost-extract-api-key-5.113.0.tgz +0 -0
  25. package/components/tryghost-ghost-5.113.0.tgz +0 -0
  26. package/components/{tryghost-html-to-plaintext-5.111.0.tgz → tryghost-html-to-plaintext-5.113.0.tgz} +0 -0
  27. package/components/tryghost-i18n-5.113.0.tgz +0 -0
  28. package/components/{tryghost-identity-token-service-5.111.0.tgz → tryghost-identity-token-service-5.113.0.tgz} +0 -0
  29. package/components/{tryghost-importer-handler-content-files-5.111.0.tgz → tryghost-importer-handler-content-files-5.113.0.tgz} +0 -0
  30. package/components/{tryghost-importer-revue-5.111.0.tgz → tryghost-importer-revue-5.113.0.tgz} +0 -0
  31. package/components/tryghost-in-memory-repository-5.113.0.tgz +0 -0
  32. package/components/tryghost-job-manager-5.113.0.tgz +0 -0
  33. package/components/tryghost-link-redirects-5.113.0.tgz +0 -0
  34. package/components/tryghost-link-replacer-5.113.0.tgz +0 -0
  35. package/components/{tryghost-magic-link-5.111.0.tgz → tryghost-magic-link-5.113.0.tgz} +0 -0
  36. package/components/tryghost-mail-events-5.113.0.tgz +0 -0
  37. package/components/tryghost-mailgun-client-5.113.0.tgz +0 -0
  38. package/components/tryghost-member-attribution-5.113.0.tgz +0 -0
  39. package/components/tryghost-member-events-5.113.0.tgz +0 -0
  40. package/components/{tryghost-members-api-5.111.0.tgz → tryghost-members-api-5.113.0.tgz} +0 -0
  41. package/components/{tryghost-members-csv-5.111.0.tgz → tryghost-members-csv-5.113.0.tgz} +0 -0
  42. package/components/{tryghost-members-importer-5.111.0.tgz → tryghost-members-importer-5.113.0.tgz} +0 -0
  43. package/components/{tryghost-members-offers-5.111.0.tgz → tryghost-members-offers-5.113.0.tgz} +0 -0
  44. package/components/tryghost-members-payments-5.113.0.tgz +0 -0
  45. package/components/{tryghost-members-ssr-5.111.0.tgz → tryghost-members-ssr-5.113.0.tgz} +0 -0
  46. package/components/{tryghost-members-stripe-service-5.111.0.tgz → tryghost-members-stripe-service-5.113.0.tgz} +0 -0
  47. package/components/tryghost-milestones-5.113.0.tgz +0 -0
  48. package/components/{tryghost-minifier-5.111.0.tgz → tryghost-minifier-5.113.0.tgz} +0 -0
  49. package/components/{tryghost-mw-api-version-mismatch-5.111.0.tgz → tryghost-mw-api-version-mismatch-5.113.0.tgz} +0 -0
  50. package/components/tryghost-mw-cache-control-5.113.0.tgz +0 -0
  51. package/components/{tryghost-mw-error-handler-5.111.0.tgz → tryghost-mw-error-handler-5.113.0.tgz} +0 -0
  52. package/components/{tryghost-mw-session-from-token-5.111.0.tgz → tryghost-mw-session-from-token-5.113.0.tgz} +0 -0
  53. package/components/{tryghost-mw-update-user-last-seen-5.111.0.tgz → tryghost-mw-update-user-last-seen-5.113.0.tgz} +0 -0
  54. package/components/{tryghost-mw-version-match-5.111.0.tgz → tryghost-mw-version-match-5.113.0.tgz} +0 -0
  55. package/components/tryghost-mw-vhost-5.113.0.tgz +0 -0
  56. package/components/{tryghost-package-json-5.111.0.tgz → tryghost-package-json-5.113.0.tgz} +0 -0
  57. package/components/tryghost-post-events-5.113.0.tgz +0 -0
  58. package/components/tryghost-post-revisions-5.113.0.tgz +0 -0
  59. package/components/{tryghost-posts-service-5.111.0.tgz → tryghost-posts-service-5.113.0.tgz} +0 -0
  60. package/components/{tryghost-prometheus-metrics-5.111.0.tgz → tryghost-prometheus-metrics-5.113.0.tgz} +0 -0
  61. package/components/tryghost-recommendations-5.113.0.tgz +0 -0
  62. package/components/tryghost-referrers-5.113.0.tgz +0 -0
  63. package/components/{tryghost-security-5.111.0.tgz → tryghost-security-5.113.0.tgz} +0 -0
  64. package/components/tryghost-session-service-5.113.0.tgz +0 -0
  65. package/components/{tryghost-settings-path-manager-5.111.0.tgz → tryghost-settings-path-manager-5.113.0.tgz} +0 -0
  66. package/components/{tryghost-slack-notifications-5.111.0.tgz → tryghost-slack-notifications-5.113.0.tgz} +0 -0
  67. package/components/{tryghost-tiers-5.111.0.tgz → tryghost-tiers-5.113.0.tgz} +0 -0
  68. package/components/tryghost-version-notifications-data-service-5.113.0.tgz +0 -0
  69. package/components/{tryghost-webmentions-5.111.0.tgz → tryghost-webmentions-5.113.0.tgz} +0 -0
  70. package/core/boot.js +0 -3
  71. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +13043 -11763
  72. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-1298238e.mjs → CodeEditorView-ed5e87be.mjs} +2 -2
  73. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  74. package/core/built/admin/assets/admin-x-settings/{index-0f51ccb5.mjs → index-0ee4d13c.mjs} +2 -2
  75. package/core/built/admin/assets/admin-x-settings/{index-2707471f.mjs → index-9c7da716.mjs} +20224 -20178
  76. package/core/built/admin/assets/admin-x-settings/{modals-f5983704.mjs → modals-7708d510.mjs} +2227 -2216
  77. package/core/built/admin/assets/{chunk.524.405c43b2cb20553b51d9.js → chunk.524.4f0aeb6b611079e528f5.js} +7 -7
  78. package/core/built/admin/assets/{chunk.582.eb4b096f29c97c9d6a64.js → chunk.582.485df00698ed27a0668b.js} +8 -8
  79. package/core/built/admin/assets/{ghost-87cffc153ec73d217c1ae9f9207ea5e1.js → ghost-ebf07ae7768b6e9fb9a4b173b6917782.js} +72 -70
  80. package/core/built/admin/assets/img/ap-nodes-01ee317529e6353a1c34a062c388f1e7.png +0 -0
  81. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +12944 -12924
  82. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +136 -134
  83. package/core/built/admin/assets/posts/posts.js +2 -2
  84. package/core/built/admin/assets/{vendor-fca15534b8426c0567400113c63a3e21.js → vendor-68a4aa424a179a90f5bbc2b750def576.js} +28 -26
  85. package/core/built/admin/index.html +4 -4
  86. package/core/frontend/helpers/get.js +2 -3
  87. package/core/frontend/services/routing/registry.js +6 -6
  88. package/core/frontend/src/admin-auth/message-handler.js +1 -1
  89. package/core/frontend/src/cards/css/cta.css +38 -36
  90. package/core/server/adapters/cache/AdapterCacheMemoryTTL.js +54 -0
  91. package/core/server/adapters/cache/memory-ttl.js +1 -1
  92. package/core/server/api/endpoints/utils/serializers/input/settings.js +2 -1
  93. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +2 -1
  94. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +2 -1
  95. package/core/server/data/migrations/versions/5.112/2025-03-10-10-01-01-add-require-mfa-setting.js +8 -0
  96. package/core/server/data/migrations/versions/5.113/2025-03-07-12-24-00-add-super-editor.js +31 -0
  97. package/core/server/data/migrations/versions/5.113/2025-03-07-12-25-00-add-member-perms-to-super-editor.js +291 -0
  98. package/core/server/data/schema/default-settings/default-settings.json +6 -0
  99. package/core/server/data/schema/fixtures/fixtures.json +27 -0
  100. package/core/server/models/invite.js +6 -7
  101. package/core/server/models/post.js +3 -9
  102. package/core/server/models/relations/authors.js +2 -4
  103. package/core/server/models/role-utils.js +38 -0
  104. package/core/server/models/role.js +7 -5
  105. package/core/server/models/user.js +41 -28
  106. package/core/server/services/activitypub/ActivityPubService.js +116 -0
  107. package/core/server/services/activitypub/ActivityPubService.ts +139 -0
  108. package/core/server/services/activitypub/ActivityPubServiceWrapper.js +1 -1
  109. package/core/server/services/email-analytics/jobs/update-member-email-analytics/index.js +13 -0
  110. package/core/server/services/email-analytics/lib/queries.js +3 -3
  111. package/core/server/services/media-inliner/ExternalMediaInliner.js +346 -0
  112. package/core/server/services/media-inliner/service.js +1 -1
  113. package/core/server/services/permissions/can-this.js +3 -2
  114. package/core/server/services/stats/MembersStatsService.js +167 -0
  115. package/core/server/services/stats/MrrStatsService.js +161 -0
  116. package/core/server/services/stats/ReferrersStatsService.js +164 -0
  117. package/core/server/services/stats/StatsService.js +63 -0
  118. package/core/server/services/stats/SubscriptionStatsService.js +180 -0
  119. package/core/server/services/stats/service.js +1 -1
  120. package/core/server/services/url/Resources.js +20 -30
  121. package/core/server/services/url/UrlService.js +2 -12
  122. package/core/server/services/url/Urls.js +17 -33
  123. package/core/shared/config/defaults.json +1 -1
  124. package/core/shared/labs.js +2 -1
  125. package/core/shared/settings-cache/CacheManager.js +4 -4
  126. package/package.json +139 -142
  127. package/tsconfig.tsbuildinfo +1 -1
  128. package/yarn.lock +40 -82
  129. package/components/tryghost-activitypub-5.111.0.tgz +0 -0
  130. package/components/tryghost-adapter-cache-memory-ttl-5.111.0.tgz +0 -0
  131. package/components/tryghost-announcement-bar-settings-5.111.0.tgz +0 -0
  132. package/components/tryghost-bookshelf-repository-5.111.0.tgz +0 -0
  133. package/components/tryghost-captcha-service-5.111.0.tgz +0 -0
  134. package/components/tryghost-constants-5.111.0.tgz +0 -0
  135. package/components/tryghost-custom-fonts-5.111.0.tgz +0 -0
  136. package/components/tryghost-custom-theme-settings-service-5.111.0.tgz +0 -0
  137. package/components/tryghost-domain-events-5.111.0.tgz +0 -0
  138. package/components/tryghost-donations-5.111.0.tgz +0 -0
  139. package/components/tryghost-email-addresses-5.111.0.tgz +0 -0
  140. package/components/tryghost-email-analytics-service-5.111.0.tgz +0 -0
  141. package/components/tryghost-email-events-5.111.0.tgz +0 -0
  142. package/components/tryghost-email-service-5.111.0.tgz +0 -0
  143. package/components/tryghost-external-media-inliner-5.111.0.tgz +0 -0
  144. package/components/tryghost-extract-api-key-5.111.0.tgz +0 -0
  145. package/components/tryghost-ghost-5.111.0.tgz +0 -0
  146. package/components/tryghost-i18n-5.111.0.tgz +0 -0
  147. package/components/tryghost-in-memory-repository-5.111.0.tgz +0 -0
  148. package/components/tryghost-job-manager-5.111.0.tgz +0 -0
  149. package/components/tryghost-link-redirects-5.111.0.tgz +0 -0
  150. package/components/tryghost-link-replacer-5.111.0.tgz +0 -0
  151. package/components/tryghost-mail-events-5.111.0.tgz +0 -0
  152. package/components/tryghost-mailgun-client-5.111.0.tgz +0 -0
  153. package/components/tryghost-member-attribution-5.111.0.tgz +0 -0
  154. package/components/tryghost-member-events-5.111.0.tgz +0 -0
  155. package/components/tryghost-members-payments-5.111.0.tgz +0 -0
  156. package/components/tryghost-milestones-5.111.0.tgz +0 -0
  157. package/components/tryghost-mw-cache-control-5.111.0.tgz +0 -0
  158. package/components/tryghost-mw-vhost-5.111.0.tgz +0 -0
  159. package/components/tryghost-post-events-5.111.0.tgz +0 -0
  160. package/components/tryghost-post-revisions-5.111.0.tgz +0 -0
  161. package/components/tryghost-recommendations-5.111.0.tgz +0 -0
  162. package/components/tryghost-referrers-5.111.0.tgz +0 -0
  163. package/components/tryghost-session-service-5.111.0.tgz +0 -0
  164. package/components/tryghost-stats-service-5.111.0.tgz +0 -0
  165. package/components/tryghost-version-notifications-data-service-5.111.0.tgz +0 -0
  166. package/core/demo.js +0 -6
  167. package/core/demo.ts +0 -3
@@ -0,0 +1,164 @@
1
+ const moment = require('moment');
2
+
3
+ class ReferrersStatsService {
4
+ /**
5
+ * @param {object} deps
6
+ * @param {import('knex').Knex} deps.knex
7
+ **/
8
+ constructor({knex}) {
9
+ this.knex = knex;
10
+ }
11
+
12
+ /**
13
+ * Return a list of all the attribution sources for a given post, with their signup and conversion counts
14
+ * @param {string} postId
15
+ * @returns {Promise<AttributionCountStat[]>}
16
+ */
17
+ async getForPost(postId) {
18
+ const knex = this.knex;
19
+ const signupRows = await knex('members_created_events')
20
+ .select('referrer_source')
21
+ .select(knex.raw('COUNT(id) AS total'))
22
+ .where('attribution_id', postId)
23
+ .where('attribution_type', 'post')
24
+ .groupBy('referrer_source');
25
+
26
+ const conversionRows = await knex('members_subscription_created_events')
27
+ .select('referrer_source')
28
+ .select(knex.raw('COUNT(id) AS total'))
29
+ .where('attribution_id', postId)
30
+ .where('attribution_type', 'post')
31
+ .groupBy('referrer_source');
32
+
33
+ // Stitch them toghether, grouping them by source
34
+
35
+ const map = new Map();
36
+ for (const row of signupRows) {
37
+ map.set(row.referrer_source, {
38
+ source: row.referrer_source,
39
+ signups: row.total,
40
+ paid_conversions: 0
41
+ });
42
+ }
43
+
44
+ for (const row of conversionRows) {
45
+ const existing = map.get(row.referrer_source) ?? {
46
+ source: row.referrer_source,
47
+ signups: 0,
48
+ paid_conversions: 0
49
+ };
50
+ existing.paid_conversions = row.total;
51
+ map.set(row.referrer_source, existing);
52
+ }
53
+
54
+ return [...map.values()].sort((a, b) => b.paid_conversions - a.paid_conversions);
55
+ }
56
+
57
+ /**
58
+ * Return a list of all the attribution sources, with their signup and conversion counts on each date
59
+ * @returns {Promise<{data: AttributionCountStat[], meta: {}}>}
60
+ */
61
+ async getReferrersHistory() {
62
+ const paidConversionEntries = await this.fetchAllPaidConversionSources();
63
+ const signupEntries = await this.fetchAllSignupSources();
64
+
65
+ const allEntries = signupEntries.map((entry) => {
66
+ return {
67
+ ...entry,
68
+ paid_conversions: 0,
69
+ date: moment(entry.date).format('YYYY-MM-DD')
70
+ };
71
+ });
72
+
73
+ paidConversionEntries.forEach((entry) => {
74
+ const entryDate = moment(entry.date).format('YYYY-MM-DD');
75
+ const existingEntry = allEntries.find(e => e.source === entry.source && e.date === entryDate);
76
+
77
+ if (existingEntry) {
78
+ existingEntry.paid_conversions = entry.paid_conversions;
79
+ } else {
80
+ allEntries.push({
81
+ ...entry,
82
+ signups: 0,
83
+ date: entryDate
84
+ });
85
+ }
86
+ });
87
+
88
+ // sort allEntries in date ascending format
89
+ allEntries.sort((a, b) => {
90
+ return moment(a.date).diff(moment(b.date));
91
+ });
92
+
93
+ return {
94
+ data: allEntries,
95
+ meta: {}
96
+ };
97
+ }
98
+
99
+ /**
100
+ * @returns {Promise<PaidConversionsCountStatDate[]>}
101
+ **/
102
+ async fetchAllPaidConversionSources() {
103
+ const knex = this.knex;
104
+ const ninetyDaysAgo = moment.utc().subtract(90, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
105
+ const rows = await knex('members_subscription_created_events')
106
+ .select(knex.raw(`DATE(created_at) as date`))
107
+ .select(knex.raw(`COUNT(*) as paid_conversions`))
108
+ .select(knex.raw(`referrer_source as source`))
109
+ .where('created_at', '>=', ninetyDaysAgo)
110
+ .groupBy('date', 'referrer_source')
111
+ .orderBy('date');
112
+
113
+ return rows;
114
+ }
115
+
116
+ /**
117
+ * @returns {Promise<SignupCountStatDate[]>}
118
+ **/
119
+ async fetchAllSignupSources() {
120
+ const knex = this.knex;
121
+ const ninetyDaysAgo = moment.utc().subtract(90, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
122
+ const rows = await knex('members_created_events')
123
+ .select(knex.raw(`DATE(created_at) as date`))
124
+ .select(knex.raw(`COUNT(*) as signups`))
125
+ .select(knex.raw(`referrer_source as source`))
126
+ .where('created_at', '>=', ninetyDaysAgo)
127
+ .groupBy('date', 'referrer_source')
128
+ .orderBy('date');
129
+
130
+ return rows;
131
+ }
132
+ }
133
+
134
+ module.exports = ReferrersStatsService;
135
+
136
+ /**
137
+ * @typedef AttributionCountStat
138
+ * @type {Object}
139
+ * @property {string} source Attribution Source
140
+ * @property {number} signups Total free members signed up for this source
141
+ * @property {number} paid_conversions Total paid conversions for this source
142
+ */
143
+
144
+ /**
145
+ * @typedef AttributionCountStatDate
146
+ * @type {AttributionCountStat}
147
+ * @property {string} date The date (YYYY-MM-DD) on which these counts were recorded
148
+ */
149
+
150
+ /**
151
+ * @typedef {object} SignupCountStatDate
152
+ * @type {Object}
153
+ * @property {string} source Attribution Source
154
+ * @property {number} signups Total free members signed up for this source
155
+ * @property {string} date The date (YYYY-MM-DD) on which these counts were recorded
156
+ **/
157
+
158
+ /**
159
+ * @typedef {object} PaidConversionsCountStatDate
160
+ * @type {Object}
161
+ * @property {string} source Attribution Source
162
+ * @property {number} paid_conversions Total paid conversions for this source
163
+ * @property {string} date The date (YYYY-MM-DD) on which these counts were recorded
164
+ **/
@@ -0,0 +1,63 @@
1
+ const MRRService = require('./MrrStatsService');
2
+ const MembersService = require('./MembersStatsService');
3
+ const SubscriptionStatsService = require('./SubscriptionStatsService');
4
+ const ReferrersStatsService = require('./ReferrersStatsService');
5
+
6
+ class StatsService {
7
+ /**
8
+ * @param {object} deps
9
+ * @param {MRRService} deps.mrr
10
+ * @param {MembersService} deps.members
11
+ * @param {SubscriptionStatsService} deps.subscriptions
12
+ * @param {ReferrersStatsService} deps.referrers
13
+ **/
14
+ constructor(deps) {
15
+ this.mrr = deps.mrr;
16
+ this.members = deps.members;
17
+ this.subscriptions = deps.subscriptions;
18
+ this.referrers = deps.referrers;
19
+ }
20
+
21
+ async getMRRHistory() {
22
+ return this.mrr.getHistory();
23
+ }
24
+
25
+ async getMemberCountHistory() {
26
+ return this.members.getCountHistory();
27
+ }
28
+
29
+ async getSubscriptionCountHistory() {
30
+ return this.subscriptions.getSubscriptionHistory();
31
+ }
32
+
33
+ async getReferrersHistory() {
34
+ return this.referrers.getReferrersHistory();
35
+ }
36
+
37
+ /**
38
+ * @param {string} postId
39
+ */
40
+ async getPostReferrers(postId) {
41
+ return {
42
+ data: await this.referrers.getForPost(postId),
43
+ meta: {}
44
+ };
45
+ }
46
+
47
+ /**
48
+ * @param {object} deps
49
+ * @param {import('knex').Knex} deps.knex
50
+ *
51
+ * @returns {StatsService}
52
+ **/
53
+ static create(deps) {
54
+ return new StatsService({
55
+ mrr: new MRRService(deps),
56
+ members: new MembersService(deps),
57
+ subscriptions: new SubscriptionStatsService(deps),
58
+ referrers: new ReferrersStatsService(deps)
59
+ });
60
+ }
61
+ }
62
+
63
+ module.exports = StatsService;
@@ -0,0 +1,180 @@
1
+ const moment = require('moment');
2
+
3
+ class SubscriptionStatsService {
4
+ /**
5
+ * @param {object} deps
6
+ * @param {import('knex').Knex} deps.knex*/
7
+ constructor({knex}) {
8
+ this.knex = knex;
9
+ }
10
+
11
+ /**
12
+ * @returns {Promise<{data: SubscriptionHistoryEntry[]}>}
13
+ **/
14
+ async getSubscriptionHistory() {
15
+ const subscriptionDeltaEntries = await this.fetchAllSubscriptionDeltas();
16
+ const counts = await this.fetchSubscriptionCounts();
17
+
18
+ /** @type {Object.<string, Object.<string, number>>} */
19
+ const countData = {};
20
+ counts.forEach((count) => {
21
+ if (!countData[count.tier]) {
22
+ countData[count.tier] = {};
23
+ }
24
+ countData[count.tier][count.cadence] = count.count;
25
+ });
26
+
27
+ /** @type {SubscriptionHistoryEntry[]} */
28
+ let subscriptionHistoryEntries = [];
29
+
30
+ /** @type {string[]} */
31
+ let cadences = [];
32
+ /** @type {string[]} */
33
+ let tiers = [];
34
+
35
+ for (let index = subscriptionDeltaEntries.length - 1; index >= 0; index -= 1) {
36
+ const entry = subscriptionDeltaEntries[index];
37
+ if (!countData[entry.tier]) {
38
+ countData[entry.tier] = {};
39
+ }
40
+ if (!countData[entry.tier][entry.cadence]) {
41
+ countData[entry.tier][entry.cadence] = 0;
42
+ }
43
+
44
+ subscriptionHistoryEntries.unshift({
45
+ ...entry,
46
+ date: moment(entry.date).format('YYYY-MM-DD'),
47
+ count: countData[entry.tier][entry.cadence]
48
+ });
49
+
50
+ countData[entry.tier][entry.cadence] += entry.negative_delta;
51
+ countData[entry.tier][entry.cadence] -= entry.positive_delta;
52
+
53
+ if (!cadences.includes(entry.cadence)) {
54
+ cadences.push(entry.cadence);
55
+ }
56
+ if (!tiers.includes(entry.tier)) {
57
+ tiers.push(entry.tier);
58
+ }
59
+ }
60
+
61
+ return {
62
+ data: subscriptionHistoryEntries,
63
+ meta: {
64
+ cadences,
65
+ tiers,
66
+ totals: counts
67
+ }
68
+ };
69
+ }
70
+
71
+ /**
72
+ * @returns {Promise<SubscriptionDelta[]>}
73
+ **/
74
+ async fetchAllSubscriptionDeltas() {
75
+ const knex = this.knex;
76
+ const rows = await knex('members_paid_subscription_events')
77
+ .join('stripe_prices AS price', function () {
78
+ this.on('price.stripe_price_id', '=', 'members_paid_subscription_events.from_plan')
79
+ .orOn('price.stripe_price_id', '=', 'members_paid_subscription_events.to_plan');
80
+ })
81
+ .join('stripe_products AS product', 'product.stripe_product_id', '=', 'price.stripe_product_id')
82
+ .join('products AS tier', 'tier.id', '=', 'product.product_id')
83
+ .leftJoin('stripe_prices AS from_price', 'from_price.stripe_price_id', '=', 'members_paid_subscription_events.from_plan')
84
+ .leftJoin('stripe_prices AS to_price', 'to_price.stripe_price_id', '=', 'members_paid_subscription_events.to_plan')
85
+ .select(knex.raw(`
86
+ DATE(members_paid_subscription_events.created_at) as date
87
+ `))
88
+ .select(knex.raw(`
89
+ tier.id as tier
90
+ `))
91
+ .select(knex.raw(`
92
+ price.interval as cadence
93
+ `))
94
+ .select(knex.raw(`SUM(
95
+ CASE
96
+ WHEN members_paid_subscription_events.type IN ('created','reactivated','active') AND members_paid_subscription_events.mrr_delta != 0 THEN 1
97
+ WHEN members_paid_subscription_events.type='updated' AND price.id = to_price.id THEN 1
98
+ WHEN members_paid_subscription_events.type='updated' AND members_paid_subscription_events.from_plan = members_paid_subscription_events.to_plan AND members_paid_subscription_events.mrr_delta > 0 THEN 1
99
+ ELSE 0
100
+ END
101
+ ) as positive_delta`))
102
+ .select(knex.raw(`SUM(
103
+ CASE
104
+ WHEN members_paid_subscription_events.type IN ('canceled', 'expired','inactive') AND members_paid_subscription_events.mrr_delta != 0 THEN 1
105
+ WHEN members_paid_subscription_events.type='updated' AND price.id = from_price.id THEN 1
106
+ ELSE 0
107
+ END
108
+ ) as negative_delta`))
109
+ .select(knex.raw(`SUM(
110
+ CASE
111
+ WHEN members_paid_subscription_events.type IN ('created','reactivated','active') AND members_paid_subscription_events.mrr_delta != 0 THEN 1
112
+ WHEN members_paid_subscription_events.type='updated' AND members_paid_subscription_events.from_plan = members_paid_subscription_events.to_plan AND members_paid_subscription_events.mrr_delta > 0 THEN 1
113
+ ELSE 0
114
+ END
115
+ ) as signups`))
116
+ .select(knex.raw(`SUM(
117
+ CASE
118
+ WHEN members_paid_subscription_events.type IN ('canceled', 'expired','inactive') AND members_paid_subscription_events.mrr_delta != 0 THEN 1
119
+ ELSE 0
120
+ END
121
+ ) as cancellations`))
122
+ .groupBy('date', 'tier', 'cadence')
123
+ .orderBy('date');
124
+
125
+ return rows;
126
+ }
127
+
128
+ /**
129
+ * Get the current total subscriptions grouped by Cadence and Tier
130
+ * @returns {Promise<SubscriptionCount[]>}
131
+ **/
132
+ async fetchSubscriptionCounts() {
133
+ const knex = this.knex;
134
+
135
+ const data = await knex('members_stripe_customers_subscriptions')
136
+ .select(knex.raw(`
137
+ COUNT(members_stripe_customers_subscriptions.id) AS count,
138
+ products.id AS tier,
139
+ stripe_prices.interval AS cadence
140
+ `))
141
+ .join('stripe_prices', 'stripe_prices.stripe_price_id', '=', 'members_stripe_customers_subscriptions.stripe_price_id')
142
+ .join('stripe_products', 'stripe_products.stripe_product_id', '=', 'stripe_prices.stripe_product_id')
143
+ .join('products', 'products.id', '=', 'stripe_products.product_id')
144
+ .whereNot('members_stripe_customers_subscriptions.mrr', 0)
145
+ .groupBy('tier', 'cadence');
146
+
147
+ return data;
148
+ }
149
+ }
150
+
151
+ /** @typedef {object} SubscriptionCount
152
+ * @prop {string} tier
153
+ * @prop {string} cadence
154
+ * @prop {number} count
155
+ **/
156
+
157
+ /**
158
+ * @typedef {object} SubscriptionDelta
159
+ * @prop {string} tier
160
+ * @prop {string} cadence
161
+ * @prop {string} date
162
+ * @prop {number} positive_delta
163
+ * @prop {number} negative_delta
164
+ * @prop {number} signups
165
+ * @prop {number} cancellations
166
+ **/
167
+
168
+ /**
169
+ * @typedef {object} SubscriptionHistoryEntry
170
+ * @prop {string} tier
171
+ * @prop {string} cadence
172
+ * @prop {string} date
173
+ * @prop {number} positive_delta
174
+ * @prop {number} negative_delta
175
+ * @prop {number} signups
176
+ * @prop {number} cancellations
177
+ * @prop {number} count
178
+ **/
179
+
180
+ module.exports = SubscriptionStatsService;
@@ -10,7 +10,7 @@ class StatsServiceWrapper {
10
10
  return;
11
11
  }
12
12
 
13
- const StatsService = require('@tryghost/stats-service');
13
+ const StatsService = require('./StatsService');
14
14
  const db = require('../../data/db');
15
15
 
16
16
  this.api = StatsService.create({knex: db.knex});
@@ -9,6 +9,8 @@ const models = require('../../models');
9
9
  // This listens to all manner of model events to find new content that needs a URL...
10
10
  const events = require('../../lib/common/events');
11
11
 
12
+ /** @typedef {'posts' | 'pages' | 'tags' | 'authors'} ResourceType */
13
+
12
14
  /**
13
15
  * @description At the moment the resources class is directly responsible for data population
14
16
  * for URLs...but because it's actually a storage cache of all published
@@ -173,7 +175,7 @@ class Resources {
173
175
  * If we resolve (https://github.com/TryGhost/Ghost/issues/10360) and talk to the Content API,
174
176
  * we could pass on e.g. `?include=authors&fields=authors.id,authors.slug`, but the API has to support it.
175
177
  *
176
- * @param {Bookshelf-Model} model
178
+ * @param {import('bookshelf').Model} model
177
179
  * @param {Object} resourceConfig
178
180
  * @private
179
181
  */
@@ -227,8 +229,8 @@ class Resources {
227
229
  * all subscribers sequentially. The first generator, where the conditions match the resource, will
228
230
  * own the resource and it's url.
229
231
  *
230
- * @param {String} type (post,user...)
231
- * @param {Bookshelf-Model} model
232
+ * @param {ResourceType} type
233
+ * @param {import('bookshelf').Model} model
232
234
  * @private
233
235
  */
234
236
  async _onResourceAdded(type, model) {
@@ -306,8 +308,8 @@ class Resources {
306
308
  * - but the data changed and is maybe no longer owned?
307
309
  * - e.g. featured:false changes and your filter requires featured posts
308
310
  *
309
- * @param {String} type (post,user...)
310
- * @param {Bookshelf-Model} model
311
+ * @param {ResourceType} type
312
+ * @param {import('bookshelf').Model} model
311
313
  * @private
312
314
  */
313
315
  async _onResourceUpdated(type, model) {
@@ -389,37 +391,25 @@ class Resources {
389
391
 
390
392
  /**
391
393
  * @description Listener for "model removed" event.
392
- * @param {String} type (post,user...)
393
- * @param {Bookshelf-Model} model
394
+ * @param {ResourceType} type
395
+ * @param {import('bookshelf').Model} model
394
396
  * @private
395
397
  */
396
398
  _onResourceRemoved(type, model) {
397
399
  debug('_onResourceRemoved', type);
398
400
 
399
- let index = null;
400
- let resource;
401
-
402
- // CASE: search for the cached resource and stop if it was found
403
- this.data[type].every((_resource, _index) => {
404
- if (_resource.data.id === model._previousAttributes.id) {
405
- resource = _resource;
406
- index = _index;
407
- // break!
408
- return false;
409
- }
410
-
411
- return true;
412
- });
401
+ const resourceId = model._previousAttributes.id;
402
+ const index = this.data[type].findIndex(resource => resource.data.id === resourceId);
413
403
 
414
- // CASE: there are possible cases that the resource was not fetched e.g. visibility is internal
415
- if (index === null) {
416
- debug('can\'t find resource', model._previousAttributes.id);
404
+ // Resource might not be in cache if e.g. visibility was internal
405
+ if (index === -1) {
406
+ debug('Resource not found in cache', resourceId);
417
407
  return;
418
408
  }
419
409
 
420
410
  // remove the resource from cache
421
- this.data[type].splice(index, 1);
422
- resource.remove();
411
+ const [removedResource] = this.data[type].splice(index, 1);
412
+ removedResource.remove();
423
413
  }
424
414
 
425
415
  /**
@@ -432,7 +422,7 @@ class Resources {
432
422
 
433
423
  /**
434
424
  * @description Get all cached resourced by type.
435
- * @param {String} type (post, user...)
425
+ * @param {ResourceType} type
436
426
  * @returns {Object}
437
427
  */
438
428
  getAllByType(type) {
@@ -441,12 +431,12 @@ class Resources {
441
431
 
442
432
  /**
443
433
  * @description Get all cached resourced by resource id and type.
444
- * @param {String} type (post, user...)
445
- * @param {String} id
434
+ * @param {ResourceType} type
435
+ * @param {string} id
446
436
  * @returns {Object}
447
437
  */
448
438
  getByIdAndType(type, id) {
449
- return _.find(this.data[type], {data: {id: id}});
439
+ return this.data[type]?.find(r => r.data.id === id);
450
440
  }
451
441
 
452
442
  /**
@@ -54,7 +54,7 @@ class UrlService {
54
54
  this.queue.addListener('started', this._onQueueStartedListener);
55
55
 
56
56
  this._onQueueEndedListener = this._onQueueEnded.bind(this);
57
- this.queue.addListener('ended', this._onQueueEnded.bind(this));
57
+ this.queue.addListener('ended', this._onQueueEndedListener);
58
58
  }
59
59
 
60
60
  /**
@@ -263,17 +263,7 @@ class UrlService {
263
263
  owns(routerId, id) {
264
264
  debug('owns', routerId, id);
265
265
 
266
- let urlGenerator;
267
-
268
- this.urlGenerators.every((_urlGenerator) => {
269
- if (_urlGenerator.identifier === routerId) {
270
- urlGenerator = _urlGenerator;
271
- return false;
272
- }
273
-
274
- return true;
275
- });
276
-
266
+ const urlGenerator = this.urlGenerators.find(g => g.identifier === routerId);
277
267
  if (!urlGenerator) {
278
268
  return false;
279
269
  }
@@ -1,4 +1,3 @@
1
- const _ = require('lodash');
2
1
  const debug = require('@tryghost/debug')('services:url:urls');
3
2
  const urlUtils = require('../../../shared/url-utils');
4
3
  const logging = require('@tryghost/logging');
@@ -7,6 +6,10 @@ const errors = require('@tryghost/errors');
7
6
  // This emits its own url added/removed events
8
7
  const events = require('../../lib/common/events');
9
8
 
9
+ /**
10
+ * @typedef {{url: string, generatorId: string, resource: import('./Resource')}} Url
11
+ */
12
+
10
13
  /**
11
14
  * This class keeps track of all urls in the system.
12
15
  * Each resource has exactly one url. Each url is owned by exactly one url generator id.
@@ -20,24 +23,14 @@ const events = require('../../lib/common/events');
20
23
  * You can easily ask `this.urls[resourceId]`.
21
24
  */
22
25
  class Urls {
23
- /**
24
- *
25
- * @param {Object} [options]
26
- * @param {Object} [options.urls] map of available URLs with their resources
27
- */
28
- constructor({urls = {}} = {}) {
29
- this.urls = urls;
30
- }
26
+ /** @type {Object<string, Url>} */
27
+ urls = {};
31
28
 
32
29
  /**
33
30
  * @description Add a url to the system.
34
- * @param {Object} options
35
- * @param {import('./Resource')} options.resource - instance of the Resource class
36
- * @param {string} options.generatorId
37
- * @param {string} options.url
31
+ * @param {Url} options
38
32
  */
39
- add(options) {
40
- const {url, generatorId, resource} = options;
33
+ add({url, generatorId, resource}) {
41
34
  debug('add', resource.data.id, url);
42
35
 
43
36
  if (this.urls[resource.data.id]) {
@@ -76,7 +69,7 @@ class Urls {
76
69
  /**
77
70
  * @description Get url by resource id.
78
71
  * @param {String} id
79
- * @returns {Object}
72
+ * @returns {Url}
80
73
  */
81
74
  getByResourceId(id) {
82
75
  return this.urls[id];
@@ -85,16 +78,10 @@ class Urls {
85
78
  /**
86
79
  * @description Get all urls by generator id.
87
80
  * @param {String} generatorId
88
- * @returns {Array}
81
+ * @returns {Url[]}
89
82
  */
90
83
  getByGeneratorId(generatorId) {
91
- return _.reduce(Object.keys(this.urls), (toReturn, resourceId) => {
92
- if (this.urls[resourceId].generatorId === generatorId) {
93
- toReturn.push(this.urls[resourceId]);
94
- }
95
-
96
- return toReturn;
97
- }, []);
84
+ return Object.values(this.urls).filter(url => url.generatorId === generatorId);
98
85
  }
99
86
 
100
87
  /**
@@ -108,20 +95,17 @@ class Urls {
108
95
  *
109
96
  * But depending on the routing registration, you will always serve e.g. resource1,
110
97
  * because the router it belongs to was registered first.
98
+ *
99
+ * @param {string} urlToLookup
100
+ * @returns {Url[]}
111
101
  */
112
- getByUrl(url) {
113
- return _.reduce(Object.keys(this.urls), (toReturn, resourceId) => {
114
- if (this.urls[resourceId].url === url) {
115
- toReturn.push(this.urls[resourceId]);
116
- }
117
-
118
- return toReturn;
119
- }, []);
102
+ getByUrl(urlToLookup) {
103
+ return Object.values(this.urls).filter(url => url.url === urlToLookup);
120
104
  }
121
105
 
122
106
  /**
123
107
  * @description Remove url.
124
- * @param id
108
+ * @param {string} id
125
109
  */
126
110
  removeResourceId(id) {
127
111
  if (!this.urls[id]) {
@@ -214,7 +214,7 @@
214
214
  },
215
215
  "comments": {
216
216
  "url": "https://cdn.jsdelivr.net/ghost/comments-ui@~{version}/umd/comments-ui.min.js",
217
- "version": "1.0"
217
+ "version": "1.1"
218
218
  },
219
219
  "signupForm": {
220
220
  "url": "https://cdn.jsdelivr.net/ghost/signup-form@~{version}/umd/signup-form.min.js",
@@ -38,7 +38,8 @@ const BETA_FEATURES = [
38
38
  'ActivityPub',
39
39
  'importMemberTier',
40
40
  'staff2fa',
41
- 'contentVisibility'
41
+ 'contentVisibility',
42
+ 'superEditors'
42
43
  ];
43
44
 
44
45
  const ALPHA_FEATURES = [