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.
- package/components/tryghost-adapter-cache-memory-ttl-5.112.0.tgz +0 -0
- package/components/{tryghost-adapter-cache-redis-5.111.0.tgz → tryghost-adapter-cache-redis-5.112.0.tgz} +0 -0
- package/components/{tryghost-adapter-manager-5.111.0.tgz → tryghost-adapter-manager-5.112.0.tgz} +0 -0
- package/components/tryghost-announcement-bar-settings-5.112.0.tgz +0 -0
- package/components/{tryghost-api-framework-5.111.0.tgz → tryghost-api-framework-5.112.0.tgz} +0 -0
- package/components/{tryghost-api-version-compatibility-service-5.111.0.tgz → tryghost-api-version-compatibility-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-audience-feedback-5.111.0.tgz → tryghost-audience-feedback-5.112.0.tgz} +0 -0
- package/components/tryghost-bookshelf-repository-5.112.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.112.0.tgz +0 -0
- package/components/{tryghost-captcha-service-5.111.0.tgz → tryghost-captcha-service-5.112.0.tgz} +0 -0
- package/components/tryghost-constants-5.112.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.112.0.tgz +0 -0
- package/components/{tryghost-custom-theme-settings-service-5.111.0.tgz → tryghost-custom-theme-settings-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-data-generator-5.111.0.tgz → tryghost-data-generator-5.112.0.tgz} +0 -0
- package/components/tryghost-domain-events-5.112.0.tgz +0 -0
- package/components/{tryghost-donations-5.111.0.tgz → tryghost-donations-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-addresses-5.111.0.tgz → tryghost-email-addresses-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-analytics-provider-mailgun-5.111.0.tgz → tryghost-email-analytics-provider-mailgun-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-analytics-service-5.111.0.tgz → tryghost-email-analytics-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-content-generator-5.111.0.tgz → tryghost-email-content-generator-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-events-5.111.0.tgz → tryghost-email-events-5.112.0.tgz} +0 -0
- package/components/tryghost-email-service-5.112.0.tgz +0 -0
- package/components/{tryghost-email-suppression-list-5.111.0.tgz → tryghost-email-suppression-list-5.112.0.tgz} +0 -0
- package/components/{tryghost-express-dynamic-redirects-5.111.0.tgz → tryghost-express-dynamic-redirects-5.112.0.tgz} +0 -0
- package/components/{tryghost-external-media-inliner-5.111.0.tgz → tryghost-external-media-inliner-5.112.0.tgz} +0 -0
- package/components/tryghost-extract-api-key-5.112.0.tgz +0 -0
- package/components/tryghost-ghost-5.112.0.tgz +0 -0
- package/components/{tryghost-html-to-plaintext-5.111.0.tgz → tryghost-html-to-plaintext-5.112.0.tgz} +0 -0
- package/components/tryghost-i18n-5.112.0.tgz +0 -0
- package/components/{tryghost-identity-token-service-5.111.0.tgz → tryghost-identity-token-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-importer-handler-content-files-5.111.0.tgz → tryghost-importer-handler-content-files-5.112.0.tgz} +0 -0
- package/components/{tryghost-importer-revue-5.111.0.tgz → tryghost-importer-revue-5.112.0.tgz} +0 -0
- package/components/tryghost-in-memory-repository-5.112.0.tgz +0 -0
- package/components/{tryghost-job-manager-5.111.0.tgz → tryghost-job-manager-5.112.0.tgz} +0 -0
- package/components/tryghost-link-redirects-5.112.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.112.0.tgz +0 -0
- package/components/{tryghost-magic-link-5.111.0.tgz → tryghost-magic-link-5.112.0.tgz} +0 -0
- package/components/{tryghost-mail-events-5.111.0.tgz → tryghost-mail-events-5.112.0.tgz} +0 -0
- package/components/{tryghost-mailgun-client-5.111.0.tgz → tryghost-mailgun-client-5.112.0.tgz} +0 -0
- package/components/{tryghost-member-attribution-5.111.0.tgz → tryghost-member-attribution-5.112.0.tgz} +0 -0
- package/components/tryghost-member-events-5.112.0.tgz +0 -0
- package/components/{tryghost-members-api-5.111.0.tgz → tryghost-members-api-5.112.0.tgz} +0 -0
- package/components/tryghost-members-csv-5.112.0.tgz +0 -0
- package/components/{tryghost-members-importer-5.111.0.tgz → tryghost-members-importer-5.112.0.tgz} +0 -0
- package/components/{tryghost-members-offers-5.111.0.tgz → tryghost-members-offers-5.112.0.tgz} +0 -0
- package/components/tryghost-members-payments-5.112.0.tgz +0 -0
- package/components/{tryghost-members-ssr-5.111.0.tgz → tryghost-members-ssr-5.112.0.tgz} +0 -0
- package/components/{tryghost-members-stripe-service-5.111.0.tgz → tryghost-members-stripe-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-milestones-5.111.0.tgz → tryghost-milestones-5.112.0.tgz} +0 -0
- package/components/{tryghost-minifier-5.111.0.tgz → tryghost-minifier-5.112.0.tgz} +0 -0
- package/components/{tryghost-mw-api-version-mismatch-5.111.0.tgz → tryghost-mw-api-version-mismatch-5.112.0.tgz} +0 -0
- package/components/tryghost-mw-cache-control-5.112.0.tgz +0 -0
- package/components/{tryghost-mw-error-handler-5.111.0.tgz → tryghost-mw-error-handler-5.112.0.tgz} +0 -0
- package/components/{tryghost-mw-session-from-token-5.111.0.tgz → tryghost-mw-session-from-token-5.112.0.tgz} +0 -0
- package/components/{tryghost-mw-update-user-last-seen-5.111.0.tgz → tryghost-mw-update-user-last-seen-5.112.0.tgz} +0 -0
- package/components/tryghost-mw-version-match-5.112.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.112.0.tgz +0 -0
- package/components/{tryghost-package-json-5.111.0.tgz → tryghost-package-json-5.112.0.tgz} +0 -0
- package/components/tryghost-post-events-5.112.0.tgz +0 -0
- package/components/tryghost-post-revisions-5.112.0.tgz +0 -0
- package/components/{tryghost-posts-service-5.111.0.tgz → tryghost-posts-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-prometheus-metrics-5.111.0.tgz → tryghost-prometheus-metrics-5.112.0.tgz} +0 -0
- package/components/tryghost-recommendations-5.112.0.tgz +0 -0
- package/components/{tryghost-referrers-5.111.0.tgz → tryghost-referrers-5.112.0.tgz} +0 -0
- package/components/{tryghost-security-5.111.0.tgz → tryghost-security-5.112.0.tgz} +0 -0
- package/components/tryghost-session-service-5.112.0.tgz +0 -0
- package/components/{tryghost-settings-path-manager-5.111.0.tgz → tryghost-settings-path-manager-5.112.0.tgz} +0 -0
- package/components/{tryghost-slack-notifications-5.111.0.tgz → tryghost-slack-notifications-5.112.0.tgz} +0 -0
- package/components/tryghost-tiers-5.112.0.tgz +0 -0
- package/components/tryghost-version-notifications-data-service-5.112.0.tgz +0 -0
- package/components/{tryghost-webmentions-5.111.0.tgz → tryghost-webmentions-5.112.0.tgz} +0 -0
- package/core/boot.js +0 -3
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +10216 -9646
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-1298238e.mjs → CodeEditorView-ad8698fe.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{index-2707471f.mjs → index-2713e469.mjs} +2750 -2714
- package/core/built/admin/assets/admin-x-settings/{index-0f51ccb5.mjs → index-463cec50.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{modals-f5983704.mjs → modals-033e8fc4.mjs} +4 -4
- package/core/built/admin/assets/{chunk.524.405c43b2cb20553b51d9.js → chunk.524.db49da6fd8ae155205a4.js} +5 -5
- package/core/built/admin/assets/{chunk.582.eb4b096f29c97c9d6a64.js → chunk.582.0bf715eb6807f7641706.js} +8 -8
- package/core/built/admin/assets/{ghost-87cffc153ec73d217c1ae9f9207ea5e1.js → ghost-62bd4d4c837d453e1038808dc1cd1e4c.js} +9 -7
- package/core/built/admin/assets/img/ap-nodes-01ee317529e6353a1c34a062c388f1e7.png +0 -0
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +12944 -12924
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +136 -134
- package/core/built/admin/assets/posts/posts.js +2 -2
- package/core/built/admin/index.html +3 -3
- package/core/frontend/helpers/get.js +2 -3
- package/core/frontend/src/cards/css/cta.css +38 -36
- package/core/server/api/endpoints/utils/serializers/input/settings.js +2 -1
- package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +2 -1
- package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +2 -1
- package/core/server/data/migrations/versions/5.112/2025-03-10-10-01-01-add-require-mfa-setting.js +8 -0
- package/core/server/data/schema/default-settings/default-settings.json +6 -0
- package/core/server/models/invite.js +4 -5
- package/core/server/models/post.js +3 -9
- package/core/server/models/relations/authors.js +2 -4
- package/core/server/models/role-utils.js +38 -0
- package/core/server/models/role.js +5 -3
- package/core/server/models/user.js +5 -3
- package/core/server/services/activitypub/ActivityPubService.js +116 -0
- package/core/server/services/activitypub/ActivityPubService.ts +139 -0
- package/core/server/services/activitypub/ActivityPubServiceWrapper.js +1 -1
- package/core/server/services/stats/MembersStatsService.js +167 -0
- package/core/server/services/stats/MrrStatsService.js +161 -0
- package/core/server/services/stats/ReferrersStatsService.js +164 -0
- package/core/server/services/stats/StatsService.js +63 -0
- package/core/server/services/stats/SubscriptionStatsService.js +180 -0
- package/core/server/services/stats/service.js +1 -1
- package/core/server/services/url/Resources.js +1 -1
- package/package.json +138 -141
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +30 -72
- package/components/tryghost-activitypub-5.111.0.tgz +0 -0
- package/components/tryghost-adapter-cache-memory-ttl-5.111.0.tgz +0 -0
- package/components/tryghost-announcement-bar-settings-5.111.0.tgz +0 -0
- package/components/tryghost-bookshelf-repository-5.111.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.111.0.tgz +0 -0
- package/components/tryghost-constants-5.111.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.111.0.tgz +0 -0
- package/components/tryghost-domain-events-5.111.0.tgz +0 -0
- package/components/tryghost-email-service-5.111.0.tgz +0 -0
- package/components/tryghost-extract-api-key-5.111.0.tgz +0 -0
- package/components/tryghost-ghost-5.111.0.tgz +0 -0
- package/components/tryghost-i18n-5.111.0.tgz +0 -0
- package/components/tryghost-in-memory-repository-5.111.0.tgz +0 -0
- package/components/tryghost-link-redirects-5.111.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.111.0.tgz +0 -0
- package/components/tryghost-member-events-5.111.0.tgz +0 -0
- package/components/tryghost-members-csv-5.111.0.tgz +0 -0
- package/components/tryghost-members-payments-5.111.0.tgz +0 -0
- package/components/tryghost-mw-cache-control-5.111.0.tgz +0 -0
- package/components/tryghost-mw-version-match-5.111.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.111.0.tgz +0 -0
- package/components/tryghost-post-events-5.111.0.tgz +0 -0
- package/components/tryghost-post-revisions-5.111.0.tgz +0 -0
- package/components/tryghost-recommendations-5.111.0.tgz +0 -0
- package/components/tryghost-session-service-5.111.0.tgz +0 -0
- package/components/tryghost-stats-service-5.111.0.tgz +0 -0
- package/components/tryghost-tiers-5.111.0.tgz +0 -0
- package/components/tryghost-version-notifications-data-service-5.111.0.tgz +0 -0
- package/core/demo.js +0 -6
- 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;
|