ghost 5.111.0 → 5.112.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 (142) hide show
  1. package/components/tryghost-adapter-cache-memory-ttl-5.112.0.tgz +0 -0
  2. package/components/{tryghost-adapter-cache-redis-5.111.0.tgz → tryghost-adapter-cache-redis-5.112.0.tgz} +0 -0
  3. package/components/{tryghost-adapter-manager-5.111.0.tgz → tryghost-adapter-manager-5.112.0.tgz} +0 -0
  4. package/components/tryghost-announcement-bar-settings-5.112.0.tgz +0 -0
  5. package/components/{tryghost-api-framework-5.111.0.tgz → tryghost-api-framework-5.112.0.tgz} +0 -0
  6. package/components/{tryghost-api-version-compatibility-service-5.111.0.tgz → tryghost-api-version-compatibility-service-5.112.0.tgz} +0 -0
  7. package/components/{tryghost-audience-feedback-5.111.0.tgz → tryghost-audience-feedback-5.112.0.tgz} +0 -0
  8. package/components/tryghost-bookshelf-repository-5.112.0.tgz +0 -0
  9. package/components/tryghost-bootstrap-socket-5.112.0.tgz +0 -0
  10. package/components/{tryghost-captcha-service-5.111.0.tgz → tryghost-captcha-service-5.112.0.tgz} +0 -0
  11. package/components/tryghost-constants-5.112.0.tgz +0 -0
  12. package/components/tryghost-custom-fonts-5.112.0.tgz +0 -0
  13. package/components/{tryghost-custom-theme-settings-service-5.111.0.tgz → tryghost-custom-theme-settings-service-5.112.0.tgz} +0 -0
  14. package/components/{tryghost-data-generator-5.111.0.tgz → tryghost-data-generator-5.112.0.tgz} +0 -0
  15. package/components/tryghost-domain-events-5.112.0.tgz +0 -0
  16. package/components/{tryghost-donations-5.111.0.tgz → tryghost-donations-5.112.0.tgz} +0 -0
  17. package/components/{tryghost-email-addresses-5.111.0.tgz → tryghost-email-addresses-5.112.0.tgz} +0 -0
  18. package/components/{tryghost-email-analytics-provider-mailgun-5.111.0.tgz → tryghost-email-analytics-provider-mailgun-5.112.0.tgz} +0 -0
  19. package/components/{tryghost-email-analytics-service-5.111.0.tgz → tryghost-email-analytics-service-5.112.0.tgz} +0 -0
  20. package/components/{tryghost-email-content-generator-5.111.0.tgz → tryghost-email-content-generator-5.112.0.tgz} +0 -0
  21. package/components/{tryghost-email-events-5.111.0.tgz → tryghost-email-events-5.112.0.tgz} +0 -0
  22. package/components/tryghost-email-service-5.112.0.tgz +0 -0
  23. package/components/{tryghost-email-suppression-list-5.111.0.tgz → tryghost-email-suppression-list-5.112.0.tgz} +0 -0
  24. package/components/{tryghost-express-dynamic-redirects-5.111.0.tgz → tryghost-express-dynamic-redirects-5.112.0.tgz} +0 -0
  25. package/components/{tryghost-external-media-inliner-5.111.0.tgz → tryghost-external-media-inliner-5.112.0.tgz} +0 -0
  26. package/components/tryghost-extract-api-key-5.112.0.tgz +0 -0
  27. package/components/tryghost-ghost-5.112.0.tgz +0 -0
  28. package/components/{tryghost-html-to-plaintext-5.111.0.tgz → tryghost-html-to-plaintext-5.112.0.tgz} +0 -0
  29. package/components/tryghost-i18n-5.112.0.tgz +0 -0
  30. package/components/{tryghost-identity-token-service-5.111.0.tgz → tryghost-identity-token-service-5.112.0.tgz} +0 -0
  31. package/components/{tryghost-importer-handler-content-files-5.111.0.tgz → tryghost-importer-handler-content-files-5.112.0.tgz} +0 -0
  32. package/components/{tryghost-importer-revue-5.111.0.tgz → tryghost-importer-revue-5.112.0.tgz} +0 -0
  33. package/components/tryghost-in-memory-repository-5.112.0.tgz +0 -0
  34. package/components/{tryghost-job-manager-5.111.0.tgz → tryghost-job-manager-5.112.0.tgz} +0 -0
  35. package/components/tryghost-link-redirects-5.112.0.tgz +0 -0
  36. package/components/tryghost-link-replacer-5.112.0.tgz +0 -0
  37. package/components/{tryghost-magic-link-5.111.0.tgz → tryghost-magic-link-5.112.0.tgz} +0 -0
  38. package/components/{tryghost-mail-events-5.111.0.tgz → tryghost-mail-events-5.112.0.tgz} +0 -0
  39. package/components/{tryghost-mailgun-client-5.111.0.tgz → tryghost-mailgun-client-5.112.0.tgz} +0 -0
  40. package/components/{tryghost-member-attribution-5.111.0.tgz → tryghost-member-attribution-5.112.0.tgz} +0 -0
  41. package/components/tryghost-member-events-5.112.0.tgz +0 -0
  42. package/components/{tryghost-members-api-5.111.0.tgz → tryghost-members-api-5.112.0.tgz} +0 -0
  43. package/components/tryghost-members-csv-5.112.0.tgz +0 -0
  44. package/components/{tryghost-members-importer-5.111.0.tgz → tryghost-members-importer-5.112.0.tgz} +0 -0
  45. package/components/{tryghost-members-offers-5.111.0.tgz → tryghost-members-offers-5.112.0.tgz} +0 -0
  46. package/components/tryghost-members-payments-5.112.0.tgz +0 -0
  47. package/components/{tryghost-members-ssr-5.111.0.tgz → tryghost-members-ssr-5.112.0.tgz} +0 -0
  48. package/components/{tryghost-members-stripe-service-5.111.0.tgz → tryghost-members-stripe-service-5.112.0.tgz} +0 -0
  49. package/components/{tryghost-milestones-5.111.0.tgz → tryghost-milestones-5.112.0.tgz} +0 -0
  50. package/components/{tryghost-minifier-5.111.0.tgz → tryghost-minifier-5.112.0.tgz} +0 -0
  51. package/components/{tryghost-mw-api-version-mismatch-5.111.0.tgz → tryghost-mw-api-version-mismatch-5.112.0.tgz} +0 -0
  52. package/components/tryghost-mw-cache-control-5.112.0.tgz +0 -0
  53. package/components/{tryghost-mw-error-handler-5.111.0.tgz → tryghost-mw-error-handler-5.112.0.tgz} +0 -0
  54. package/components/{tryghost-mw-session-from-token-5.111.0.tgz → tryghost-mw-session-from-token-5.112.0.tgz} +0 -0
  55. package/components/{tryghost-mw-update-user-last-seen-5.111.0.tgz → tryghost-mw-update-user-last-seen-5.112.0.tgz} +0 -0
  56. package/components/tryghost-mw-version-match-5.112.0.tgz +0 -0
  57. package/components/tryghost-mw-vhost-5.112.0.tgz +0 -0
  58. package/components/{tryghost-package-json-5.111.0.tgz → tryghost-package-json-5.112.0.tgz} +0 -0
  59. package/components/tryghost-post-events-5.112.0.tgz +0 -0
  60. package/components/tryghost-post-revisions-5.112.0.tgz +0 -0
  61. package/components/{tryghost-posts-service-5.111.0.tgz → tryghost-posts-service-5.112.0.tgz} +0 -0
  62. package/components/{tryghost-prometheus-metrics-5.111.0.tgz → tryghost-prometheus-metrics-5.112.0.tgz} +0 -0
  63. package/components/tryghost-recommendations-5.112.0.tgz +0 -0
  64. package/components/{tryghost-referrers-5.111.0.tgz → tryghost-referrers-5.112.0.tgz} +0 -0
  65. package/components/{tryghost-security-5.111.0.tgz → tryghost-security-5.112.0.tgz} +0 -0
  66. package/components/tryghost-session-service-5.112.0.tgz +0 -0
  67. package/components/{tryghost-settings-path-manager-5.111.0.tgz → tryghost-settings-path-manager-5.112.0.tgz} +0 -0
  68. package/components/{tryghost-slack-notifications-5.111.0.tgz → tryghost-slack-notifications-5.112.0.tgz} +0 -0
  69. package/components/tryghost-tiers-5.112.0.tgz +0 -0
  70. package/components/tryghost-version-notifications-data-service-5.112.0.tgz +0 -0
  71. package/components/{tryghost-webmentions-5.111.0.tgz → tryghost-webmentions-5.112.0.tgz} +0 -0
  72. package/core/boot.js +0 -3
  73. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +10216 -9646
  74. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-1298238e.mjs → CodeEditorView-ad8698fe.mjs} +2 -2
  75. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  76. package/core/built/admin/assets/admin-x-settings/{index-2707471f.mjs → index-2713e469.mjs} +2750 -2714
  77. package/core/built/admin/assets/admin-x-settings/{index-0f51ccb5.mjs → index-463cec50.mjs} +2 -2
  78. package/core/built/admin/assets/admin-x-settings/{modals-f5983704.mjs → modals-033e8fc4.mjs} +4 -4
  79. package/core/built/admin/assets/{chunk.524.405c43b2cb20553b51d9.js → chunk.524.db49da6fd8ae155205a4.js} +5 -5
  80. package/core/built/admin/assets/{chunk.582.eb4b096f29c97c9d6a64.js → chunk.582.0bf715eb6807f7641706.js} +8 -8
  81. package/core/built/admin/assets/{ghost-87cffc153ec73d217c1ae9f9207ea5e1.js → ghost-62bd4d4c837d453e1038808dc1cd1e4c.js} +9 -7
  82. package/core/built/admin/assets/img/ap-nodes-01ee317529e6353a1c34a062c388f1e7.png +0 -0
  83. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +12944 -12924
  84. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +136 -134
  85. package/core/built/admin/assets/posts/posts.js +2 -2
  86. package/core/built/admin/index.html +3 -3
  87. package/core/frontend/helpers/get.js +2 -3
  88. package/core/frontend/src/cards/css/cta.css +38 -36
  89. package/core/server/api/endpoints/utils/serializers/input/settings.js +2 -1
  90. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +2 -1
  91. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +2 -1
  92. package/core/server/data/migrations/versions/5.112/2025-03-10-10-01-01-add-require-mfa-setting.js +8 -0
  93. package/core/server/data/schema/default-settings/default-settings.json +6 -0
  94. package/core/server/models/invite.js +4 -5
  95. package/core/server/models/post.js +3 -9
  96. package/core/server/models/relations/authors.js +2 -4
  97. package/core/server/models/role-utils.js +38 -0
  98. package/core/server/models/role.js +5 -3
  99. package/core/server/models/user.js +5 -3
  100. package/core/server/services/activitypub/ActivityPubService.js +116 -0
  101. package/core/server/services/activitypub/ActivityPubService.ts +139 -0
  102. package/core/server/services/activitypub/ActivityPubServiceWrapper.js +1 -1
  103. package/core/server/services/stats/MembersStatsService.js +167 -0
  104. package/core/server/services/stats/MrrStatsService.js +161 -0
  105. package/core/server/services/stats/ReferrersStatsService.js +164 -0
  106. package/core/server/services/stats/StatsService.js +63 -0
  107. package/core/server/services/stats/SubscriptionStatsService.js +180 -0
  108. package/core/server/services/stats/service.js +1 -1
  109. package/core/server/services/url/Resources.js +1 -1
  110. package/package.json +138 -141
  111. package/tsconfig.tsbuildinfo +1 -1
  112. package/yarn.lock +30 -72
  113. package/components/tryghost-activitypub-5.111.0.tgz +0 -0
  114. package/components/tryghost-adapter-cache-memory-ttl-5.111.0.tgz +0 -0
  115. package/components/tryghost-announcement-bar-settings-5.111.0.tgz +0 -0
  116. package/components/tryghost-bookshelf-repository-5.111.0.tgz +0 -0
  117. package/components/tryghost-bootstrap-socket-5.111.0.tgz +0 -0
  118. package/components/tryghost-constants-5.111.0.tgz +0 -0
  119. package/components/tryghost-custom-fonts-5.111.0.tgz +0 -0
  120. package/components/tryghost-domain-events-5.111.0.tgz +0 -0
  121. package/components/tryghost-email-service-5.111.0.tgz +0 -0
  122. package/components/tryghost-extract-api-key-5.111.0.tgz +0 -0
  123. package/components/tryghost-ghost-5.111.0.tgz +0 -0
  124. package/components/tryghost-i18n-5.111.0.tgz +0 -0
  125. package/components/tryghost-in-memory-repository-5.111.0.tgz +0 -0
  126. package/components/tryghost-link-redirects-5.111.0.tgz +0 -0
  127. package/components/tryghost-link-replacer-5.111.0.tgz +0 -0
  128. package/components/tryghost-member-events-5.111.0.tgz +0 -0
  129. package/components/tryghost-members-csv-5.111.0.tgz +0 -0
  130. package/components/tryghost-members-payments-5.111.0.tgz +0 -0
  131. package/components/tryghost-mw-cache-control-5.111.0.tgz +0 -0
  132. package/components/tryghost-mw-version-match-5.111.0.tgz +0 -0
  133. package/components/tryghost-mw-vhost-5.111.0.tgz +0 -0
  134. package/components/tryghost-post-events-5.111.0.tgz +0 -0
  135. package/components/tryghost-post-revisions-5.111.0.tgz +0 -0
  136. package/components/tryghost-recommendations-5.111.0.tgz +0 -0
  137. package/components/tryghost-session-service-5.111.0.tgz +0 -0
  138. package/components/tryghost-stats-service-5.111.0.tgz +0 -0
  139. package/components/tryghost-tiers-5.111.0.tgz +0 -0
  140. package/components/tryghost-version-notifications-data-service-5.111.0.tgz +0 -0
  141. package/core/demo.js +0 -6
  142. package/core/demo.ts +0 -3
@@ -0,0 +1,161 @@
1
+ const moment = require('moment');
2
+
3
+ class MrrStatsService {
4
+ /**
5
+ * @param {object} deps
6
+ * @param {import('knex').Knex} deps.knex
7
+ **/
8
+ constructor({knex}) {
9
+ this.knex = knex;
10
+ }
11
+
12
+ /**
13
+ * Get the current total MRR, grouped by currency (ascending order)
14
+ * @returns {Promise<MrrByCurrency[]>}
15
+ */
16
+ async getCurrentMrr() {
17
+ const knex = this.knex;
18
+ const rows = await knex('members_stripe_customers_subscriptions')
19
+ .select(knex.raw(`plan_currency as currency`))
20
+ .select(knex.raw(`SUM(mrr) AS mrr`))
21
+ .groupBy('plan_currency')
22
+ .orderBy('currency');
23
+
24
+ if (rows.length === 0) {
25
+ // Add a USD placeholder to always have at least one currency
26
+ rows.push({
27
+ currency: 'usd',
28
+ mrr: 0
29
+ });
30
+ }
31
+
32
+ return rows;
33
+ }
34
+
35
+ /**
36
+ * Get the MRR deltas for all days (from old to new), grouped by currency (ascending alphabetically)
37
+ * @returns {Promise<MrrDelta[]>} The deltas sorted from new to old
38
+ */
39
+ async fetchAllDeltas() {
40
+ const knex = this.knex;
41
+ const ninetyDaysAgo = moment.utc().subtract(90, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
42
+ const rows = await knex('members_paid_subscription_events')
43
+ .select('currency')
44
+ // In SQLite, DATE(created_at) would map to a string value, while DATE(created_at) would map to a JSDate object in MySQL
45
+ // That is why we need the cast here (to have some consistency)
46
+ .select(knex.raw('CAST(DATE(created_at) as CHAR) as date'))
47
+ .select(knex.raw(`SUM(mrr_delta) as delta`))
48
+ .where('created_at', '>=', ninetyDaysAgo)
49
+ .groupByRaw('CAST(DATE(created_at) as CHAR), currency');
50
+ return rows;
51
+ }
52
+
53
+ /**
54
+ * Returns a list of the MRR history for each day and currency, including the current MRR per currency as meta data.
55
+ * The respons is in ascending date order, and currencies for the same date are always in ascending order.
56
+ * @returns {Promise<MrrHistory>}
57
+ */
58
+ async getHistory() {
59
+ // Fetch current total amounts and start counting from there
60
+ const totals = await this.getCurrentMrr();
61
+
62
+ const rows = await this.fetchAllDeltas();
63
+
64
+ rows.sort((rowA, rowB) => {
65
+ const dateA = new Date(rowA.date);
66
+ const dateB = new Date(rowB.date);
67
+
68
+ return dateA - dateB || rowA.currency.localeCompare(rowB.currency);
69
+ });
70
+
71
+ // Get today in UTC (default timezone)
72
+ const today = moment().format('YYYY-MM-DD');
73
+
74
+ const results = [];
75
+
76
+ // Create a map of the totals by currency for fast lookup and editing
77
+
78
+ /** @type {Object.<string, number>}*/
79
+ const currentTotals = {};
80
+ for (const total of totals) {
81
+ currentTotals[total.currency] = total.mrr;
82
+ }
83
+
84
+ // Loop in reverse order (needed to have correct sorted result)
85
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
86
+ const row = rows[i];
87
+
88
+ if (currentTotals[row.currency] === undefined) {
89
+ // Skip unexpected currencies that are not in the totals
90
+ continue;
91
+ }
92
+
93
+ // Convert JSDates to YYYY-MM-DD (in UTC)
94
+ const date = moment(row.date).format('YYYY-MM-DD');
95
+
96
+ if (date > today) {
97
+ // Skip results that are in the future for some reason
98
+ continue;
99
+ }
100
+
101
+ results.unshift({
102
+ date,
103
+ mrr: Math.max(0, currentTotals[row.currency]),
104
+ currency: row.currency
105
+ });
106
+
107
+ currentTotals[row.currency] -= row.delta;
108
+ }
109
+
110
+ // Now also add the oldest days we have left over and do not have deltas
111
+ const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
112
+
113
+ // Note that we also need to loop the totals in reverse order because we need to unshift
114
+ for (let i = totals.length - 1; i >= 0; i -= 1) {
115
+ const total = totals[i];
116
+ results.unshift({
117
+ date: oldestDate,
118
+ mrr: Math.max(0, currentTotals[total.currency]),
119
+ currency: total.currency
120
+ });
121
+ }
122
+
123
+ return {
124
+ data: results,
125
+ meta: {
126
+ totals
127
+ }
128
+ };
129
+ }
130
+ }
131
+
132
+ module.exports = MrrStatsService;
133
+
134
+ /**
135
+ * @typedef MrrByCurrency
136
+ * @type {Object}
137
+ * @property {number} mrr
138
+ * @property {string} currency
139
+ */
140
+
141
+ /**
142
+ * @typedef MrrDelta
143
+ * @type {Object}
144
+ * @property {Date} date
145
+ * @property {string} currency
146
+ * @property {number} delta MRR change on this day
147
+ */
148
+
149
+ /**
150
+ * @typedef {Object} MrrRecord
151
+ * @property {string} date In YYYY-MM-DD format
152
+ * @property {string} currency
153
+ * @property {number} mrr MRR on this day
154
+ */
155
+
156
+ /**
157
+ * @typedef {Object} MrrHistory
158
+ * @property {MrrRecord[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
159
+ * @property {Object} meta
160
+ * @property {MrrByCurrency[]} meta.totals
161
+ */
@@ -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});
@@ -446,7 +446,7 @@ class Resources {
446
446
  * @returns {Object}
447
447
  */
448
448
  getByIdAndType(type, id) {
449
- return _.find(this.data[type], {data: {id: id}});
449
+ return this.data[type]?.find(r => r.data.id === id);
450
450
  }
451
451
 
452
452
  /**