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.
- package/components/{tryghost-adapter-cache-redis-5.111.0.tgz → tryghost-adapter-cache-redis-5.113.0.tgz} +0 -0
- package/components/{tryghost-adapter-manager-5.111.0.tgz → tryghost-adapter-manager-5.113.0.tgz} +0 -0
- package/components/tryghost-announcement-bar-settings-5.113.0.tgz +0 -0
- package/components/{tryghost-api-framework-5.111.0.tgz → tryghost-api-framework-5.113.0.tgz} +0 -0
- package/components/{tryghost-api-version-compatibility-service-5.111.0.tgz → tryghost-api-version-compatibility-service-5.113.0.tgz} +0 -0
- package/components/{tryghost-audience-feedback-5.111.0.tgz → tryghost-audience-feedback-5.113.0.tgz} +0 -0
- package/components/tryghost-bookshelf-repository-5.113.0.tgz +0 -0
- package/components/{tryghost-bootstrap-socket-5.111.0.tgz → tryghost-bootstrap-socket-5.113.0.tgz} +0 -0
- package/components/tryghost-captcha-service-5.113.0.tgz +0 -0
- package/components/tryghost-constants-5.113.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.113.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.113.0.tgz +0 -0
- package/components/{tryghost-data-generator-5.111.0.tgz → tryghost-data-generator-5.113.0.tgz} +0 -0
- package/components/tryghost-domain-events-5.113.0.tgz +0 -0
- package/components/tryghost-donations-5.113.0.tgz +0 -0
- package/components/tryghost-email-addresses-5.113.0.tgz +0 -0
- package/components/{tryghost-email-analytics-provider-mailgun-5.111.0.tgz → tryghost-email-analytics-provider-mailgun-5.113.0.tgz} +0 -0
- package/components/tryghost-email-analytics-service-5.113.0.tgz +0 -0
- package/components/{tryghost-email-content-generator-5.111.0.tgz → tryghost-email-content-generator-5.113.0.tgz} +0 -0
- package/components/tryghost-email-events-5.113.0.tgz +0 -0
- package/components/tryghost-email-service-5.113.0.tgz +0 -0
- package/components/{tryghost-email-suppression-list-5.111.0.tgz → tryghost-email-suppression-list-5.113.0.tgz} +0 -0
- package/components/{tryghost-express-dynamic-redirects-5.111.0.tgz → tryghost-express-dynamic-redirects-5.113.0.tgz} +0 -0
- package/components/tryghost-extract-api-key-5.113.0.tgz +0 -0
- package/components/tryghost-ghost-5.113.0.tgz +0 -0
- package/components/{tryghost-html-to-plaintext-5.111.0.tgz → tryghost-html-to-plaintext-5.113.0.tgz} +0 -0
- package/components/tryghost-i18n-5.113.0.tgz +0 -0
- package/components/{tryghost-identity-token-service-5.111.0.tgz → tryghost-identity-token-service-5.113.0.tgz} +0 -0
- package/components/{tryghost-importer-handler-content-files-5.111.0.tgz → tryghost-importer-handler-content-files-5.113.0.tgz} +0 -0
- package/components/{tryghost-importer-revue-5.111.0.tgz → tryghost-importer-revue-5.113.0.tgz} +0 -0
- package/components/tryghost-in-memory-repository-5.113.0.tgz +0 -0
- package/components/tryghost-job-manager-5.113.0.tgz +0 -0
- package/components/tryghost-link-redirects-5.113.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.113.0.tgz +0 -0
- package/components/{tryghost-magic-link-5.111.0.tgz → tryghost-magic-link-5.113.0.tgz} +0 -0
- package/components/tryghost-mail-events-5.113.0.tgz +0 -0
- package/components/tryghost-mailgun-client-5.113.0.tgz +0 -0
- package/components/tryghost-member-attribution-5.113.0.tgz +0 -0
- package/components/tryghost-member-events-5.113.0.tgz +0 -0
- package/components/{tryghost-members-api-5.111.0.tgz → tryghost-members-api-5.113.0.tgz} +0 -0
- package/components/{tryghost-members-csv-5.111.0.tgz → tryghost-members-csv-5.113.0.tgz} +0 -0
- package/components/{tryghost-members-importer-5.111.0.tgz → tryghost-members-importer-5.113.0.tgz} +0 -0
- package/components/{tryghost-members-offers-5.111.0.tgz → tryghost-members-offers-5.113.0.tgz} +0 -0
- package/components/tryghost-members-payments-5.113.0.tgz +0 -0
- package/components/{tryghost-members-ssr-5.111.0.tgz → tryghost-members-ssr-5.113.0.tgz} +0 -0
- package/components/{tryghost-members-stripe-service-5.111.0.tgz → tryghost-members-stripe-service-5.113.0.tgz} +0 -0
- package/components/tryghost-milestones-5.113.0.tgz +0 -0
- package/components/{tryghost-minifier-5.111.0.tgz → tryghost-minifier-5.113.0.tgz} +0 -0
- package/components/{tryghost-mw-api-version-mismatch-5.111.0.tgz → tryghost-mw-api-version-mismatch-5.113.0.tgz} +0 -0
- package/components/tryghost-mw-cache-control-5.113.0.tgz +0 -0
- package/components/{tryghost-mw-error-handler-5.111.0.tgz → tryghost-mw-error-handler-5.113.0.tgz} +0 -0
- package/components/{tryghost-mw-session-from-token-5.111.0.tgz → tryghost-mw-session-from-token-5.113.0.tgz} +0 -0
- package/components/{tryghost-mw-update-user-last-seen-5.111.0.tgz → tryghost-mw-update-user-last-seen-5.113.0.tgz} +0 -0
- package/components/{tryghost-mw-version-match-5.111.0.tgz → tryghost-mw-version-match-5.113.0.tgz} +0 -0
- package/components/tryghost-mw-vhost-5.113.0.tgz +0 -0
- package/components/{tryghost-package-json-5.111.0.tgz → tryghost-package-json-5.113.0.tgz} +0 -0
- package/components/tryghost-post-events-5.113.0.tgz +0 -0
- package/components/tryghost-post-revisions-5.113.0.tgz +0 -0
- package/components/{tryghost-posts-service-5.111.0.tgz → tryghost-posts-service-5.113.0.tgz} +0 -0
- package/components/{tryghost-prometheus-metrics-5.111.0.tgz → tryghost-prometheus-metrics-5.113.0.tgz} +0 -0
- package/components/tryghost-recommendations-5.113.0.tgz +0 -0
- package/components/tryghost-referrers-5.113.0.tgz +0 -0
- package/components/{tryghost-security-5.111.0.tgz → tryghost-security-5.113.0.tgz} +0 -0
- package/components/tryghost-session-service-5.113.0.tgz +0 -0
- package/components/{tryghost-settings-path-manager-5.111.0.tgz → tryghost-settings-path-manager-5.113.0.tgz} +0 -0
- package/components/{tryghost-slack-notifications-5.111.0.tgz → tryghost-slack-notifications-5.113.0.tgz} +0 -0
- package/components/{tryghost-tiers-5.111.0.tgz → tryghost-tiers-5.113.0.tgz} +0 -0
- package/components/tryghost-version-notifications-data-service-5.113.0.tgz +0 -0
- package/components/{tryghost-webmentions-5.111.0.tgz → tryghost-webmentions-5.113.0.tgz} +0 -0
- package/core/boot.js +0 -3
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +13043 -11763
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-1298238e.mjs → CodeEditorView-ed5e87be.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-0f51ccb5.mjs → index-0ee4d13c.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-2707471f.mjs → index-9c7da716.mjs} +20224 -20178
- package/core/built/admin/assets/admin-x-settings/{modals-f5983704.mjs → modals-7708d510.mjs} +2227 -2216
- package/core/built/admin/assets/{chunk.524.405c43b2cb20553b51d9.js → chunk.524.4f0aeb6b611079e528f5.js} +7 -7
- package/core/built/admin/assets/{chunk.582.eb4b096f29c97c9d6a64.js → chunk.582.485df00698ed27a0668b.js} +8 -8
- package/core/built/admin/assets/{ghost-87cffc153ec73d217c1ae9f9207ea5e1.js → ghost-ebf07ae7768b6e9fb9a4b173b6917782.js} +72 -70
- 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/assets/{vendor-fca15534b8426c0567400113c63a3e21.js → vendor-68a4aa424a179a90f5bbc2b750def576.js} +28 -26
- package/core/built/admin/index.html +4 -4
- package/core/frontend/helpers/get.js +2 -3
- package/core/frontend/services/routing/registry.js +6 -6
- package/core/frontend/src/admin-auth/message-handler.js +1 -1
- package/core/frontend/src/cards/css/cta.css +38 -36
- package/core/server/adapters/cache/AdapterCacheMemoryTTL.js +54 -0
- package/core/server/adapters/cache/memory-ttl.js +1 -1
- 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/migrations/versions/5.113/2025-03-07-12-24-00-add-super-editor.js +31 -0
- package/core/server/data/migrations/versions/5.113/2025-03-07-12-25-00-add-member-perms-to-super-editor.js +291 -0
- package/core/server/data/schema/default-settings/default-settings.json +6 -0
- package/core/server/data/schema/fixtures/fixtures.json +27 -0
- package/core/server/models/invite.js +6 -7
- 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 +7 -5
- package/core/server/models/user.js +41 -28
- 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/email-analytics/jobs/update-member-email-analytics/index.js +13 -0
- package/core/server/services/email-analytics/lib/queries.js +3 -3
- package/core/server/services/media-inliner/ExternalMediaInliner.js +346 -0
- package/core/server/services/media-inliner/service.js +1 -1
- package/core/server/services/permissions/can-this.js +3 -2
- 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 +20 -30
- package/core/server/services/url/UrlService.js +2 -12
- package/core/server/services/url/Urls.js +17 -33
- package/core/shared/config/defaults.json +1 -1
- package/core/shared/labs.js +2 -1
- package/core/shared/settings-cache/CacheManager.js +4 -4
- package/package.json +139 -142
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +40 -82
- 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-captcha-service-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-custom-theme-settings-service-5.111.0.tgz +0 -0
- package/components/tryghost-domain-events-5.111.0.tgz +0 -0
- package/components/tryghost-donations-5.111.0.tgz +0 -0
- package/components/tryghost-email-addresses-5.111.0.tgz +0 -0
- package/components/tryghost-email-analytics-service-5.111.0.tgz +0 -0
- package/components/tryghost-email-events-5.111.0.tgz +0 -0
- package/components/tryghost-email-service-5.111.0.tgz +0 -0
- package/components/tryghost-external-media-inliner-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-job-manager-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-mail-events-5.111.0.tgz +0 -0
- package/components/tryghost-mailgun-client-5.111.0.tgz +0 -0
- package/components/tryghost-member-attribution-5.111.0.tgz +0 -0
- package/components/tryghost-member-events-5.111.0.tgz +0 -0
- package/components/tryghost-members-payments-5.111.0.tgz +0 -0
- package/components/tryghost-milestones-5.111.0.tgz +0 -0
- package/components/tryghost-mw-cache-control-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-referrers-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-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,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;
|
|
@@ -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 {
|
|
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 {
|
|
231
|
-
* @param {
|
|
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 {
|
|
310
|
-
* @param {
|
|
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 {
|
|
393
|
-
* @param {
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
//
|
|
415
|
-
if (index ===
|
|
416
|
-
debug('
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
445
|
-
* @param {
|
|
434
|
+
* @param {ResourceType} type
|
|
435
|
+
* @param {string} id
|
|
446
436
|
* @returns {Object}
|
|
447
437
|
*/
|
|
448
438
|
getByIdAndType(type, id) {
|
|
449
|
-
return
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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 {
|
|
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 {
|
|
81
|
+
* @returns {Url[]}
|
|
89
82
|
*/
|
|
90
83
|
getByGeneratorId(generatorId) {
|
|
91
|
-
return
|
|
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(
|
|
113
|
-
return
|
|
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.
|
|
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",
|