ghost 5.110.4 → 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 (202) hide show
  1. package/components/tryghost-adapter-cache-memory-ttl-5.112.0.tgz +0 -0
  2. package/components/tryghost-adapter-cache-redis-5.112.0.tgz +0 -0
  3. package/components/{tryghost-adapter-manager-5.110.4.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.110.4.tgz → tryghost-api-framework-5.112.0.tgz} +0 -0
  6. package/components/tryghost-api-version-compatibility-service-5.112.0.tgz +0 -0
  7. package/components/{tryghost-audience-feedback-5.110.4.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.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.110.4.tgz → tryghost-custom-theme-settings-service-5.112.0.tgz} +0 -0
  14. package/components/{tryghost-data-generator-5.110.4.tgz → tryghost-data-generator-5.112.0.tgz} +0 -0
  15. package/components/{tryghost-domain-events-5.110.4.tgz → tryghost-domain-events-5.112.0.tgz} +0 -0
  16. package/components/tryghost-donations-5.112.0.tgz +0 -0
  17. package/components/tryghost-email-addresses-5.112.0.tgz +0 -0
  18. package/components/tryghost-email-analytics-provider-mailgun-5.112.0.tgz +0 -0
  19. package/components/{tryghost-email-analytics-service-5.110.4.tgz → tryghost-email-analytics-service-5.112.0.tgz} +0 -0
  20. package/components/tryghost-email-content-generator-5.112.0.tgz +0 -0
  21. package/components/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.112.0.tgz +0 -0
  24. package/components/{tryghost-express-dynamic-redirects-5.110.4.tgz → tryghost-express-dynamic-redirects-5.112.0.tgz} +0 -0
  25. package/components/{tryghost-external-media-inliner-5.110.4.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.110.4.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.112.0.tgz +0 -0
  31. package/components/tryghost-importer-handler-content-files-5.112.0.tgz +0 -0
  32. package/components/{tryghost-importer-revue-5.110.4.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.110.4.tgz → tryghost-job-manager-5.112.0.tgz} +0 -0
  35. package/components/{tryghost-link-redirects-5.110.4.tgz → 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.110.4.tgz → tryghost-magic-link-5.112.0.tgz} +0 -0
  38. package/components/tryghost-mail-events-5.112.0.tgz +0 -0
  39. package/components/tryghost-mailgun-client-5.112.0.tgz +0 -0
  40. package/components/{tryghost-member-attribution-5.110.4.tgz → tryghost-member-attribution-5.112.0.tgz} +0 -0
  41. package/components/{tryghost-member-events-5.110.4.tgz → tryghost-member-events-5.112.0.tgz} +0 -0
  42. package/components/{tryghost-members-api-5.110.4.tgz → tryghost-members-api-5.112.0.tgz} +0 -0
  43. package/components/{tryghost-members-csv-5.110.4.tgz → tryghost-members-csv-5.112.0.tgz} +0 -0
  44. package/components/{tryghost-members-importer-5.110.4.tgz → tryghost-members-importer-5.112.0.tgz} +0 -0
  45. package/components/{tryghost-members-offers-5.110.4.tgz → tryghost-members-offers-5.112.0.tgz} +0 -0
  46. package/components/{tryghost-members-payments-5.110.4.tgz → tryghost-members-payments-5.112.0.tgz} +0 -0
  47. package/components/{tryghost-members-ssr-5.110.4.tgz → tryghost-members-ssr-5.112.0.tgz} +0 -0
  48. package/components/{tryghost-members-stripe-service-5.110.4.tgz → tryghost-members-stripe-service-5.112.0.tgz} +0 -0
  49. package/components/{tryghost-milestones-5.110.4.tgz → tryghost-milestones-5.112.0.tgz} +0 -0
  50. package/components/tryghost-minifier-5.112.0.tgz +0 -0
  51. package/components/tryghost-mw-api-version-mismatch-5.112.0.tgz +0 -0
  52. package/components/{tryghost-mw-cache-control-5.110.4.tgz → tryghost-mw-cache-control-5.112.0.tgz} +0 -0
  53. package/components/tryghost-mw-error-handler-5.112.0.tgz +0 -0
  54. package/components/{tryghost-mw-session-from-token-5.110.4.tgz → tryghost-mw-session-from-token-5.112.0.tgz} +0 -0
  55. package/components/{tryghost-mw-update-user-last-seen-5.110.4.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.112.0.tgz +0 -0
  59. package/components/{tryghost-post-events-5.110.4.tgz → tryghost-post-events-5.112.0.tgz} +0 -0
  60. package/components/{tryghost-post-revisions-5.110.4.tgz → tryghost-post-revisions-5.112.0.tgz} +0 -0
  61. package/components/{tryghost-posts-service-5.110.4.tgz → tryghost-posts-service-5.112.0.tgz} +0 -0
  62. package/components/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.112.0.tgz +0 -0
  65. package/components/{tryghost-security-5.110.4.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.112.0.tgz +0 -0
  68. package/components/{tryghost-slack-notifications-5.110.4.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.112.0.tgz +0 -0
  72. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +12799 -10270
  73. package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +2 -2
  74. package/core/built/admin/assets/admin-x-demo/{index-82e381fb.mjs → index-0040480a.mjs} +3252 -2891
  75. package/core/built/admin/assets/admin-x-demo/{modals-b20a9ede.mjs → modals-fb35c86c.mjs} +2 -2
  76. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-ea62c29b.mjs → CodeEditorView-ad8698fe.mjs} +624 -618
  77. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +3 -3
  78. package/core/built/admin/assets/admin-x-settings/{index-af8cf9cf.mjs → index-2713e469.mjs} +6892 -6469
  79. package/core/built/admin/assets/admin-x-settings/{index-4b25c788.mjs → index-463cec50.mjs} +2 -2
  80. package/core/built/admin/assets/admin-x-settings/{modals-cb2dc7b7.mjs → modals-033e8fc4.mjs} +7888 -7669
  81. package/core/built/admin/assets/{chunk.524.3096e68df5b51dacf872.js → chunk.524.db49da6fd8ae155205a4.js} +6 -6
  82. package/core/built/admin/assets/{chunk.582.e225422f90639ff30544.js → chunk.582.0bf715eb6807f7641706.js} +8 -8
  83. package/core/built/admin/assets/{ghost-98d002d50a5e01d2100b2c387a849249.js → ghost-62bd4d4c837d453e1038808dc1cd1e4c.js} +43 -42
  84. package/core/built/admin/assets/img/ap-nodes-01ee317529e6353a1c34a062c388f1e7.png +0 -0
  85. package/core/built/admin/assets/koenig-lexical/index.css +1 -1
  86. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +18314 -17680
  87. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +229 -200
  88. package/core/built/admin/assets/posts/posts.js +24137 -24156
  89. package/core/built/admin/index.html +3 -3
  90. package/core/frontend/helpers/get.js +2 -3
  91. package/core/frontend/services/sitemap/SiteMapManager.js +1 -1
  92. package/core/frontend/src/cards/css/cta.css +40 -30
  93. package/core/frontend/src/cards/css/video.css +1 -0
  94. package/core/server/api/endpoints/settings-public.js +3 -2
  95. package/core/server/api/endpoints/utils/serializers/input/settings.js +3 -1
  96. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +2 -1
  97. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +2 -1
  98. package/core/server/data/migrations/versions/5.111/2025-03-05-16-36-39-add-captcha-setting.js +8 -0
  99. package/core/server/data/migrations/versions/5.112/2025-03-10-10-01-01-add-require-mfa-setting.js +8 -0
  100. package/core/server/data/schema/default-settings/default-settings.json +14 -0
  101. package/core/server/models/invite.js +4 -5
  102. package/core/server/models/post.js +3 -9
  103. package/core/server/models/relations/authors.js +2 -4
  104. package/core/server/models/role-utils.js +38 -0
  105. package/core/server/models/role.js +5 -3
  106. package/core/server/models/user.js +5 -3
  107. package/core/server/services/activitypub/ActivityPubService.js +116 -0
  108. package/core/server/services/activitypub/ActivityPubService.ts +139 -0
  109. package/core/server/services/activitypub/ActivityPubServiceWrapper.js +1 -1
  110. package/core/server/services/link-tracking/ClickEvent.js +25 -0
  111. package/core/server/services/link-tracking/FullPostLink.js +36 -0
  112. package/core/server/services/link-tracking/LinkClickRepository.js +1 -1
  113. package/core/server/services/link-tracking/LinkClickTrackingService.js +237 -0
  114. package/core/server/services/link-tracking/PostLink.js +29 -0
  115. package/core/server/services/link-tracking/PostLinkRepository.js +2 -2
  116. package/core/server/services/link-tracking/index.js +1 -1
  117. package/core/server/services/members-events/EventStorage.js +61 -0
  118. package/core/server/services/members-events/LastSeenAtCache.js +96 -0
  119. package/core/server/services/members-events/LastSeenAtUpdater.js +192 -0
  120. package/core/server/services/members-events/index.js +3 -1
  121. package/core/server/services/mentions-email-report/MentionEmailReportJob.js +117 -0
  122. package/core/server/services/mentions-email-report/service.js +3 -3
  123. package/core/server/services/staff/StaffService.js +179 -0
  124. package/core/server/services/staff/StaffServiceEmails.js +527 -0
  125. package/core/server/services/staff/email-templates/donation.hbs +119 -0
  126. package/core/server/services/staff/email-templates/donation.txt.js +15 -0
  127. package/core/server/services/staff/email-templates/mention-report.hbs +136 -0
  128. package/core/server/services/staff/email-templates/mention-report.txt.js +19 -0
  129. package/core/server/services/staff/email-templates/new-free-signup.hbs +118 -0
  130. package/core/server/services/staff/email-templates/new-free-signup.txt.js +13 -0
  131. package/core/server/services/staff/email-templates/new-milestone-received.hbs +142 -0
  132. package/core/server/services/staff/email-templates/new-milestone-received.txt.js +13 -0
  133. package/core/server/services/staff/email-templates/new-paid-cancellation.hbs +125 -0
  134. package/core/server/services/staff/email-templates/new-paid-cancellation.txt.js +13 -0
  135. package/core/server/services/staff/email-templates/new-paid-started.hbs +124 -0
  136. package/core/server/services/staff/email-templates/new-paid-started.txt.js +13 -0
  137. package/core/server/services/staff/email-templates/partials/preview.hbs +6 -0
  138. package/core/server/services/staff/email-templates/partials/styles.hbs +114 -0
  139. package/core/server/services/staff/email-templates/recommendation-received.hbs +154 -0
  140. package/core/server/services/staff/email-templates/recommendation-received.txt.js +13 -0
  141. package/core/server/services/staff/index.js +1 -1
  142. package/core/server/services/staff/milestone-email-config.js +207 -0
  143. package/core/server/services/stats/MembersStatsService.js +167 -0
  144. package/core/server/services/stats/MrrStatsService.js +161 -0
  145. package/core/server/services/stats/ReferrersStatsService.js +164 -0
  146. package/core/server/services/stats/StatsService.js +63 -0
  147. package/core/server/services/stats/SubscriptionStatsService.js +180 -0
  148. package/core/server/services/stats/service.js +1 -1
  149. package/core/server/services/url/Resources.js +2 -2
  150. package/core/shared/config/defaults.json +2 -1
  151. package/core/shared/events/URLResourceUpdatedEvent.js +33 -0
  152. package/core/shared/settings-cache/public.js +1 -0
  153. package/package.json +155 -158
  154. package/tsconfig.json +105 -0
  155. package/tsconfig.tsbuildinfo +1 -0
  156. package/yarn.lock +347 -136
  157. package/components/tryghost-activitypub-5.110.4.tgz +0 -0
  158. package/components/tryghost-adapter-cache-memory-ttl-5.110.4.tgz +0 -0
  159. package/components/tryghost-adapter-cache-redis-5.110.4.tgz +0 -0
  160. package/components/tryghost-announcement-bar-settings-5.110.4.tgz +0 -0
  161. package/components/tryghost-api-version-compatibility-service-5.110.4.tgz +0 -0
  162. package/components/tryghost-bookshelf-repository-5.110.4.tgz +0 -0
  163. package/components/tryghost-bootstrap-socket-5.110.4.tgz +0 -0
  164. package/components/tryghost-captcha-service-5.110.4.tgz +0 -0
  165. package/components/tryghost-constants-5.110.4.tgz +0 -0
  166. package/components/tryghost-custom-fonts-5.110.4.tgz +0 -0
  167. package/components/tryghost-donations-5.110.4.tgz +0 -0
  168. package/components/tryghost-dynamic-routing-events-5.110.4.tgz +0 -0
  169. package/components/tryghost-email-addresses-5.110.4.tgz +0 -0
  170. package/components/tryghost-email-analytics-provider-mailgun-5.110.4.tgz +0 -0
  171. package/components/tryghost-email-content-generator-5.110.4.tgz +0 -0
  172. package/components/tryghost-email-events-5.110.4.tgz +0 -0
  173. package/components/tryghost-email-service-5.110.4.tgz +0 -0
  174. package/components/tryghost-email-suppression-list-5.110.4.tgz +0 -0
  175. package/components/tryghost-extract-api-key-5.110.4.tgz +0 -0
  176. package/components/tryghost-ghost-5.110.4.tgz +0 -0
  177. package/components/tryghost-i18n-5.110.4.tgz +0 -0
  178. package/components/tryghost-identity-token-service-5.110.4.tgz +0 -0
  179. package/components/tryghost-importer-handler-content-files-5.110.4.tgz +0 -0
  180. package/components/tryghost-in-memory-repository-5.110.4.tgz +0 -0
  181. package/components/tryghost-link-replacer-5.110.4.tgz +0 -0
  182. package/components/tryghost-link-tracking-5.110.4.tgz +0 -0
  183. package/components/tryghost-mail-events-5.110.4.tgz +0 -0
  184. package/components/tryghost-mailgun-client-5.110.4.tgz +0 -0
  185. package/components/tryghost-members-events-service-5.110.4.tgz +0 -0
  186. package/components/tryghost-mentions-email-report-5.110.4.tgz +0 -0
  187. package/components/tryghost-minifier-5.110.4.tgz +0 -0
  188. package/components/tryghost-mw-api-version-mismatch-5.110.4.tgz +0 -0
  189. package/components/tryghost-mw-error-handler-5.110.4.tgz +0 -0
  190. package/components/tryghost-mw-version-match-5.110.4.tgz +0 -0
  191. package/components/tryghost-mw-vhost-5.110.4.tgz +0 -0
  192. package/components/tryghost-package-json-5.110.4.tgz +0 -0
  193. package/components/tryghost-prometheus-metrics-5.110.4.tgz +0 -0
  194. package/components/tryghost-recommendations-5.110.4.tgz +0 -0
  195. package/components/tryghost-referrers-5.110.4.tgz +0 -0
  196. package/components/tryghost-session-service-5.110.4.tgz +0 -0
  197. package/components/tryghost-settings-path-manager-5.110.4.tgz +0 -0
  198. package/components/tryghost-staff-service-5.110.4.tgz +0 -0
  199. package/components/tryghost-stats-service-5.110.4.tgz +0 -0
  200. package/components/tryghost-tiers-5.110.4.tgz +0 -0
  201. package/components/tryghost-version-notifications-data-service-5.110.4.tgz +0 -0
  202. package/components/tryghost-webmentions-5.110.4.tgz +0 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ *
3
+ * @param {string} siteTitle
4
+ * @param {string} formattedValue
5
+ *
6
+ * @returns {Object.<string, object>}
7
+ */
8
+ const milestoneEmailConfig = (siteTitle, formattedValue) => {
9
+ const arrContent = {
10
+ subject: `${siteTitle} hit ${formattedValue} ARR`,
11
+ heading: `Congrats! You reached ${formattedValue} ARR`,
12
+ content: [
13
+ `<strong>${siteTitle}</strong> is now generating <strong>${formattedValue}</strong> in annual recurring revenue. Congratulations &mdash; this is a significant milestone.`,
14
+ 'Subscription revenue is predictable and sustainable, meaning you can keep focusing on delivering great content while watching your business grow. Keep up the great work. See you at the next milestone!'
15
+ ],
16
+ ctaText: 'Login to your dashboard'
17
+ };
18
+
19
+ return {
20
+ // For ARR we use the same content and only the image changes
21
+ // Should we start to support different currencies, we'll need
22
+ // to update the structure for ARR content to reflect that.
23
+ arr: {
24
+ 100: {
25
+ ...arrContent,
26
+ image: {
27
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-100.png',
28
+ height: 348
29
+ }
30
+ },
31
+ 1000: {
32
+ ...arrContent,
33
+ image: {
34
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-1000.png',
35
+ height: 348
36
+ }
37
+ },
38
+ 10000: {
39
+ ...arrContent,
40
+ image: {
41
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-10k.png',
42
+ height: 348
43
+ }
44
+ },
45
+ 50000: {
46
+ ...arrContent,
47
+ image: {
48
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-50k.png',
49
+ height: 348
50
+ }
51
+ },
52
+ 100000: {
53
+ ...arrContent,
54
+ heading: `Congrats! You reached $100k ARR`,
55
+ image: {
56
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-100k.png',
57
+ height: 348
58
+ }
59
+ },
60
+ 250000: {
61
+ ...arrContent,
62
+ heading: `Congrats! You reached $250k ARR`,
63
+ image: {
64
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-250k.png',
65
+ height: 348
66
+ }
67
+ },
68
+ 500000: {
69
+ ...arrContent,
70
+ heading: `Congrats! You reached $500k ARR`,
71
+ image: {
72
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-500k.png',
73
+ height: 348
74
+ }
75
+ },
76
+ 1000000: {
77
+ ...arrContent,
78
+ heading: `Congrats! You reached $1m ARR`,
79
+ image: {
80
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-usd-1m.png',
81
+ height: 348
82
+ }
83
+ }
84
+ },
85
+ members: {
86
+ 100: {
87
+ subject: `${siteTitle} has ${formattedValue} members 🤗`,
88
+ heading: `Milestone achieved: ${formattedValue} signups`,
89
+ content: [
90
+ 'All the hard work in getting your publication up and running paid off, and your work has since gone on to inspire more than <strong>100 people</strong> to sign up. This is the first major milestone in growing an online audience, and you’ve made it here!',
91
+ 'So what’s next?',
92
+ 'If you keep up the great work you’ll be well on your way to growing an even bigger audience. In the meantime, here’s some actionable advice about <strong><a href="https://ghost.org/resources/first-1000-email-subscribers/">how to reach the next major milestones</a></strong>.',
93
+ 'You got this!'
94
+ ],
95
+ ctaText: 'Login to your dashboard',
96
+ image: {
97
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-100.png'
98
+ }
99
+ },
100
+ 1000: {
101
+ subject: `${siteTitle} now has ${formattedValue} members`,
102
+ heading: `You have ${formattedValue} true fans`,
103
+ content: [
104
+ `Congrats, <strong>${siteTitle}</strong> has officially reached <strong>${formattedValue} member signups</strong>.`,
105
+ 'This is such an impressive milestone and according to Kevin Kelly’s true fan <a href="https://kk.org/thetechnium/1000-true-fans/">theory</a>, it means you now have a direct relationship with enough people to run a truly independent creator business online.',
106
+ `Imagine ${formattedValue} people all in one room at the same time. That's a lot of people. It's also how many people are happy that you show up to create your work. Very cool. Keep up the great work!`
107
+ ],
108
+ ctaText: 'See your member stats',
109
+ image: {
110
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-1000.png'
111
+ }
112
+ },
113
+ 10000: {
114
+ subject: `${siteTitle} now has 10k members`,
115
+ heading: 'Huge success: 10k members',
116
+ content: [
117
+ `There are now <strong>10k people</strong> who enjoy <strong>${siteTitle}</strong> so much they decided to sign up as members.`,
118
+ 'Building an audience of any size as an independent creator requires dedication, and reaching this incredible milestone is an impressive feat worth celebrating. There’s no stopping you now, keep up the great work!'
119
+ ],
120
+ ctaText: 'Go to your dashboard',
121
+ image: {
122
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-10k.png'
123
+ }
124
+ },
125
+ 25000: {
126
+ subject: `${siteTitle} now has 25k members`,
127
+ heading: `Celebrating ${formattedValue} signups`,
128
+ content: [
129
+ 'Congrats, <strong>25k people</strong> have chosen to support and follow your work. That’s an audience big enough to sell out Madison Square Garden. What an incredible milestone!',
130
+ 'It takes a lot of work and dedication to build an audience as an independent creator, so here’s to recognizing what you’ve achieved.',
131
+ 'Keep up the great work!'
132
+ ],
133
+ ctaText: 'View your dashboard',
134
+ image: {
135
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-25k.png'
136
+ }
137
+ },
138
+ 50000: {
139
+ subject: `${siteTitle} now has 50k members`,
140
+ heading: `${formattedValue} people love your work`,
141
+ content: [
142
+ `It's time to pop the champagne because <strong>${siteTitle}</strong> has officially reached <strong>50k members</strong>. At this rate of growth you can almost fill a Superbowl stadium 🏈`,
143
+ 'Building an audience of this size is an incredible achievement, so hats off to you. Keep up the amazing work.',
144
+ 'See you at the next milestone!'
145
+ ],
146
+ ctaText: 'Go to your Dashboard',
147
+ image: {
148
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-50k.png'
149
+ }
150
+ },
151
+ 100000: {
152
+ subject: `${siteTitle} just hit 100k members!`,
153
+ heading: `You just reached ${formattedValue} members`,
154
+ content: [
155
+ 'Congratulations &mdash; your work has attracted an audience of <strong>100k people</strong> from around the world. Fun fact: Your audience is now big enough to fill any of the largest stadiums in the United States.',
156
+ 'Whatever you’re doing, it’s working. The sky is the limit from here. Keep up the great work (but first, go and celebrate this impressive milestone, you earned it).'
157
+ ],
158
+ ctaText: 'Go to your dashboard',
159
+ image: {
160
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-100k.png'
161
+ }
162
+ },
163
+ 250000: {
164
+ subject: `${siteTitle} now has 250k members`,
165
+ heading: 'Celebrating 250k member signups',
166
+ content: [
167
+ `One-quarter of a million people enjoy and support <strong>${siteTitle}</strong>. That’s the same number of people who make up the crowds at the SXSW festival.`,
168
+ 'You’re officially in the top 5% of creators using Ghost 🚀',
169
+ 'Reaching this milestone is no easy feat, so make sure you take some time to recognize how far you’ve come.',
170
+ 'Keep up the amazing work!'
171
+ ],
172
+ ctaText: 'Go to your dashboard',
173
+ image: {
174
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-250k.png'
175
+ }
176
+ },
177
+ 500000: {
178
+ subject: `${siteTitle} has ${formattedValue} members`,
179
+ heading: `Half a million members!`,
180
+ content: [
181
+ `Congrats, <strong>${siteTitle}</strong> has officially attracted an audience of more than <strong>${formattedValue} people</strong>, and counting.`,
182
+ 'You’re officially in the top 3% of creators using Ghost. ',
183
+ 'It takes a huge amount of hard work and dedication to build an audience of this size. It is a testament to how much value your work is providing to thousands of people all over the world. Keep up the great work, and make sure to take the time to celebrate this incredible milestone.'
184
+ ],
185
+ ctaText: 'Login to your dashboard',
186
+ image: {
187
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-500k.png'
188
+ }
189
+ },
190
+ 1000000: {
191
+ subject: `${siteTitle} has 1 million members`,
192
+ heading: `You did it. 1 million members 🏆`,
193
+ content: [
194
+ `Start writing your acceptance speech! The <strong>${siteTitle}</strong> audience is now officially big enough to headline an event at the Copacabana, with more than <strong>1 million members</strong>. That puts you in the top 1% of creators using Ghost.`,
195
+ 'In all seriousness, this is an <em>incredible</em> achievement and something to be very proud of. You deserve all the credit as a truly independent creator.',
196
+ 'Keep it up, you’re creating amazing value in the world!'
197
+ ],
198
+ ctaText: 'Go to your dashboard',
199
+ image: {
200
+ url: 'https://static.ghost.org/v5.0.0/images/milestone-email-members-1m.png'
201
+ }
202
+ }
203
+ }
204
+ };
205
+ };
206
+
207
+ module.exports = milestoneEmailConfig;
@@ -0,0 +1,167 @@
1
+ const moment = require('moment');
2
+
3
+ class MembersStatsService {
4
+ /**
5
+ * @param {object} deps
6
+ * @param {import('knex').Knex} deps.knex*/
7
+ constructor({knex}) {
8
+ this.knex = knex;
9
+ }
10
+
11
+ /**
12
+ * Get the current total members grouped by status
13
+ * @returns {Promise<TotalMembersByStatus>}
14
+ */
15
+ async getCount() {
16
+ const knex = this.knex;
17
+ const rows = await knex('members')
18
+ .select('status')
19
+ .select(knex.raw('COUNT(id) AS total'))
20
+ .groupBy('status');
21
+
22
+ const paidEvent = rows.find(c => c.status === 'paid');
23
+ const freeEvent = rows.find(c => c.status === 'free');
24
+ const compedEvent = rows.find(c => c.status === 'comped');
25
+
26
+ return {
27
+ paid: paidEvent ? paidEvent.total : 0,
28
+ free: freeEvent ? freeEvent.total : 0,
29
+ comped: compedEvent ? compedEvent.total : 0
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Get the member deltas by status for all days, sorted ascending
35
+ * @returns {Promise<MemberStatusDelta[]>} The deltas of paid, free and comped users per day, sorted ascending
36
+ */
37
+ async fetchAllStatusDeltas() {
38
+ const knex = this.knex;
39
+ const ninetyDaysAgo = moment.utc().subtract(91, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
40
+ const rows = await knex('members_status_events')
41
+ .select(knex.raw('DATE(created_at) as date'))
42
+ .select(knex.raw(`SUM(
43
+ CASE WHEN to_status='paid' THEN 1
44
+ ELSE 0 END
45
+ ) as paid_subscribed`))
46
+ .select(knex.raw(`SUM(
47
+ CASE WHEN from_status='paid' THEN 1
48
+ ELSE 0 END
49
+ ) as paid_canceled`))
50
+ .select(knex.raw(`SUM(
51
+ CASE WHEN to_status='comped' THEN 1
52
+ WHEN from_status='comped' THEN -1
53
+ ELSE 0 END
54
+ ) as comped_delta`))
55
+ .select(knex.raw(`SUM(
56
+ CASE WHEN to_status='free' THEN 1
57
+ WHEN from_status='free' THEN -1
58
+ ELSE 0 END
59
+ ) as free_delta`))
60
+ .where('created_at', '>=', ninetyDaysAgo)
61
+ .groupByRaw('DATE(created_at)');
62
+ return rows;
63
+ }
64
+
65
+ /**
66
+ * Returns a list of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
67
+ * @returns {Promise<CountHistory>}
68
+ */
69
+ async getCountHistory() {
70
+ const rows = await this.fetchAllStatusDeltas();
71
+
72
+ // Fetch current total amounts and start counting from there
73
+ const totals = await this.getCount();
74
+ let {paid, free, comped} = totals;
75
+
76
+ // Get today in UTC (default timezone)
77
+ const today = moment().format('YYYY-MM-DD');
78
+
79
+ const cumulativeResults = [];
80
+
81
+ rows.sort((a, b) => new Date(a.date) - new Date(b.date));
82
+ // Loop in reverse order (needed to have correct sorted result)
83
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
84
+ const row = rows[i];
85
+
86
+ // Convert JSDates to YYYY-MM-DD (in UTC)
87
+ const date = moment(row.date).format('YYYY-MM-DD');
88
+ if (date > today) {
89
+ // Skip results that are in the future (fix for invalid events)
90
+ continue;
91
+ }
92
+ cumulativeResults.unshift({
93
+ date,
94
+ paid: Math.max(0, paid),
95
+ free: Math.max(0, free),
96
+ comped: Math.max(0, comped),
97
+
98
+ // Deltas
99
+ paid_subscribed: row.paid_subscribed,
100
+ paid_canceled: row.paid_canceled
101
+ });
102
+
103
+ // Update current counts
104
+ paid -= row.paid_subscribed - row.paid_canceled;
105
+ free -= row.free_delta;
106
+ comped -= row.comped_delta;
107
+ }
108
+
109
+ // Now also add the oldest day we have left over (this one will be zero, which is also needed as a data point for graphs)
110
+ const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
111
+
112
+ cumulativeResults.unshift({
113
+ date: oldestDate,
114
+ paid: Math.max(0, paid),
115
+ free: Math.max(0, free),
116
+ comped: Math.max(0, comped),
117
+
118
+ // Deltas
119
+ paid_subscribed: 0,
120
+ paid_canceled: 0
121
+ });
122
+
123
+ return {
124
+ data: cumulativeResults,
125
+ meta: {
126
+ totals
127
+ }
128
+ };
129
+ }
130
+ }
131
+
132
+ module.exports = MembersStatsService;
133
+
134
+ /**
135
+ * @typedef MemberStatusDelta
136
+ * @type {Object}
137
+ * @property {Date} date
138
+ * @property {number} paid_subscribed Paid members that subscribed on this day
139
+ * @property {number} paid_canceled Paid members that canceled on this day
140
+ * @property {number} comped_delta Total net comped members on this day
141
+ * @property {number} free_delta Total net members on this day
142
+ */
143
+
144
+ /**
145
+ * @typedef TotalMembersByStatus
146
+ * @type {Object}
147
+ * @property {number} paid Total paid members
148
+ * @property {number} free Total free members
149
+ * @property {number} comped Total comped members
150
+ */
151
+
152
+ /**
153
+ * @typedef {Object} TotalMembersByStatusItem
154
+ * @property {string} date In YYYY-MM-DD format
155
+ * @property {number} paid Total paid members
156
+ * @property {number} free Total free members
157
+ * @property {number} comped Total comped members
158
+ * @property {number} paid_subscribed Paid members that subscribed on this day
159
+ * @property {number} paid_canceled Paid members that canceled on this day
160
+ */
161
+
162
+ /**
163
+ * @typedef {Object} CountHistory
164
+ * @property {TotalMembersByStatusItem[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
165
+ * @property {Object} meta
166
+ * @property {TotalMembersByStatus} meta.totals
167
+ */
@@ -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
+ */