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,192 @@
1
+ const {MemberPageViewEvent, MemberCommentEvent, MemberLinkClickEvent} = require('@tryghost/member-events');
2
+ const moment = require('moment-timezone');
3
+ const {IncorrectUsageError} = require('@tryghost/errors');
4
+ const {EmailOpenedEvent} = require('@tryghost/email-events');
5
+ const logging = require('@tryghost/logging');
6
+ const LastSeenAtCache = require('./LastSeenAtCache');
7
+
8
+ /**
9
+ * Listen for `MemberViewEvent` to update the `member.last_seen_at` timestamp
10
+ */
11
+ class LastSeenAtUpdater {
12
+ /**
13
+ * Initializes the event subscriber
14
+ * @param {Object} deps dependencies
15
+ * @param {Object} deps.services The list of service dependencies
16
+ * @param {any} deps.services.settingsCache The settings service
17
+ * @param {() => object} deps.getMembersApi - A function which returns an instance of members-api
18
+ * @param {any} deps.db Database connection
19
+ * @param {any} deps.events The event emitter
20
+ * @param {any} deps.lastSeenAtCache An instance of the last seen at cache
21
+ * @param {any} deps.config Ghost config for click tracking
22
+ */
23
+ constructor({
24
+ services: {
25
+ settingsCache
26
+ },
27
+ getMembersApi,
28
+ db,
29
+ events,
30
+ lastSeenAtCache,
31
+ config
32
+ }) {
33
+ if (!getMembersApi) {
34
+ throw new IncorrectUsageError({message: 'Missing option getMembersApi'});
35
+ }
36
+
37
+ this._getMembersApi = getMembersApi;
38
+ this._settingsCacheService = settingsCache;
39
+ this._db = db;
40
+ this._events = events;
41
+ this._lastSeenAtCache = lastSeenAtCache || new LastSeenAtCache({services: {settingsCache}});
42
+ this._config = config;
43
+ }
44
+ /**
45
+ * Subscribe to events of this domainEvents service
46
+ * @param {any} domainEvents The DomainEvents service
47
+ */
48
+ subscribe(domainEvents) {
49
+ domainEvents.subscribe(MemberPageViewEvent, async (event) => {
50
+ try {
51
+ await this.cachedUpdateLastSeenAt(event.data.memberId, event.data.memberLastSeenAt, event.timestamp);
52
+ } catch (err) {
53
+ logging.error(`Error in LastSeenAtUpdater.MemberPageViewEvent listener for member ${event.data.memberId}`);
54
+ logging.error(err);
55
+ }
56
+ });
57
+
58
+ // Only disable if explicitly set to false in config
59
+ const shouldUpdateForClickTracking = !this._config || this._config.get('backgroundJobs:clickTrackingLastSeenAtUpdater') !== false;
60
+ if (shouldUpdateForClickTracking) {
61
+ domainEvents.subscribe(MemberLinkClickEvent, async (event) => {
62
+ try {
63
+ await this.cachedUpdateLastSeenAt(event.data.memberId, event.data.memberLastSeenAt, event.timestamp);
64
+ } catch (err) {
65
+ logging.error(`Error in LastSeenAtUpdater.MemberLinkClickEvent listener for member ${event.data.memberId}`);
66
+ logging.error(err);
67
+ }
68
+ });
69
+ }
70
+
71
+ domainEvents.subscribe(MemberCommentEvent, async (event) => {
72
+ try {
73
+ await this.updateLastCommentedAt(event.data.memberId, event.timestamp);
74
+ } catch (err) {
75
+ logging.error(`Error in LastSeenAtUpdater.MemberCommentEvent listener for member ${event.data.memberId}`);
76
+ logging.error(err);
77
+ }
78
+ });
79
+
80
+ domainEvents.subscribe(EmailOpenedEvent, async (event) => {
81
+ try {
82
+ await this.updateLastSeenAtWithoutKnownLastSeen(event.memberId, event.timestamp);
83
+ } catch (err) {
84
+ logging.error(`Error in LastSeenAtUpdater.EmailOpenedEvent listener for member ${event.memberId}, emailRecipientId ${event.emailRecipientId}`);
85
+ logging.error(err);
86
+ }
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Updates the member.last_seen_at field if it wasn't updated in the current day yet (in the publication timezone)
92
+ * Example: current time is 2022-02-28 18:00:00
93
+ * - memberLastSeenAt is 2022-02-27 23:00:00, timestamp is current time, then `last_seen_at` is set to the current time
94
+ * - memberLastSeenAt is 2022-02-28 01:00:00, timestamp is current time, then `last_seen_at` isn't changed
95
+ * @param {string} memberId The id of the member to be udpated
96
+ * @param {Date} timestamp The event timestamp
97
+ */
98
+ async updateLastSeenAtWithoutKnownLastSeen(memberId, timestamp) {
99
+ // Note: we are not using Bookshelf / member repository to prevent firing webhooks + to prevent deadlock issues
100
+ // If we would use the member repostiory, we would create a forUpdate lock when editing the member, including when fetching the member labels. Creating a possible deadlock if somewhere else we do the reverse in a transaction.
101
+ const timezone = this._settingsCacheService.get('timezone') || 'Etc/UTC';
102
+ const startOfDayInSiteTimezone = moment.utc(timestamp).tz(timezone).startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
103
+ const formattedTimestamp = moment.utc(timestamp).format('YYYY-MM-DD HH:mm:ss');
104
+ await this._db.knex('members')
105
+ .where('id', '=', memberId)
106
+ .andWhere(builder => builder
107
+ .where('last_seen_at', '<', startOfDayInSiteTimezone)
108
+ .orWhereNull('last_seen_at')
109
+ )
110
+ .update({
111
+ last_seen_at: formattedTimestamp
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Updates the member.last_seen_at field if it wasn't updated in the current day yet (in the publication timezone)
117
+ */
118
+ async cachedUpdateLastSeenAt(memberId, memberLastSeenAt, timestamp) {
119
+ if (this._lastSeenAtCache.shouldUpdateMember(memberId)) {
120
+ await this.updateLastSeenAt(memberId, memberLastSeenAt, timestamp);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Updates the member.last_seen_at field if it wasn't updated in the current day yet (in the publication timezone)
126
+ * Example: current time is 2022-02-28 18:00:00
127
+ * - memberLastSeenAt is 2022-02-27 23:00:00, timestamp is current time, then `last_seen_at` is set to the current time
128
+ * - memberLastSeenAt is 2022-02-28 01:00:00, timestamp is current time, then `last_seen_at` isn't changed
129
+ * @param {string} memberId The id of the member to be udpated
130
+ * @param {Date} timestamp The event timestamp
131
+ */
132
+ async updateLastSeenAt(memberId, memberLastSeenAt, timestamp) {
133
+ const timezone = this._settingsCacheService.get('timezone');
134
+ // First, check if memberLastSeenAt is null or before the beginning of the current day in the publication timezone
135
+ // This isn't strictly necessary since we will fetch the member row for update and double check this
136
+ // This is an optimization to avoid unnecessary database queries if last_seen_at is already after the beginning of the current day
137
+ if (memberLastSeenAt === null || moment(moment.utc(timestamp).tz(timezone).startOf('day')).isAfter(memberLastSeenAt)) {
138
+ try {
139
+ // Pre-emptively update local cache so we don't update the same member again in the same day
140
+ this._lastSeenAtCache.add(memberId);
141
+ const membersApi = this._getMembersApi();
142
+ await this._db.knex.transaction(async (trx) => {
143
+ // To avoid a race condition, we lock the member row for update, then the last_seen_at field again to prevent simultaneous updates
144
+ const currentMember = await membersApi.members.get({id: memberId}, {require: true, transacting: trx, forUpdate: true});
145
+ const currentMemberLastSeenAt = currentMember.get('last_seen_at');
146
+ if (currentMemberLastSeenAt === null || moment(moment.utc(timestamp).tz(timezone).startOf('day')).isAfter(currentMemberLastSeenAt)) {
147
+ const memberToUpdate = await currentMember.refresh({transacting: trx, forUpdate: false, withRelated: ['labels', 'newsletters']});
148
+ const updatedMember = await memberToUpdate.save({last_seen_at: moment.utc(timestamp).format('YYYY-MM-DD HH:mm:ss')}, {transacting: trx, patch: true, method: 'update'});
149
+ // The standard event doesn't get emitted inside the transaction, so we do it manually
150
+ this._events.emit('member.edited', updatedMember);
151
+ return Promise.resolve(updatedMember);
152
+ }
153
+ return Promise.resolve(undefined);
154
+ });
155
+ } catch (err) {
156
+ // Remove the member from the cache if an error occurs
157
+ // This is to ensure that the member is updated on the next event if this one fails
158
+ this._lastSeenAtCache.remove(memberId);
159
+ // Bubble up the error to the event listener
160
+ throw err;
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Updates the member.last_seen_at field if it wasn't updated in the current day yet (in the publication timezone)
167
+ * Example: current time is 2022-02-28 18:00:00
168
+ * - memberLastSeenAt is 2022-02-27 23:00:00, timestamp is current time, then `last_seen_at` is set to the current time
169
+ * - memberLastSeenAt is 2022-02-28 01:00:00, timestamp is current time, then `last_seen_at` isn't changed
170
+ * @param {string} memberId The id of the member to be udpated
171
+ * @param {Date} timestamp The event timestamp
172
+ */
173
+ async updateLastCommentedAt(memberId, timestamp) {
174
+ const membersApi = this._getMembersApi();
175
+ const member = await membersApi.members.get({id: memberId}, {require: true});
176
+ const timezone = this._settingsCacheService.get('timezone');
177
+
178
+ const memberLastSeenAt = member.get('last_seen_at');
179
+ const memberLastCommentedAt = member.get('last_commented_at');
180
+
181
+ if (memberLastSeenAt === null || moment(moment.utc(timestamp).tz(timezone).startOf('day')).isAfter(memberLastSeenAt) || memberLastCommentedAt === null || moment(moment.utc(timestamp).tz(timezone).startOf('day')).isAfter(memberLastCommentedAt)) {
182
+ await membersApi.members.update({
183
+ last_seen_at: moment.utc(timestamp).format('YYYY-MM-DD HH:mm:ss'),
184
+ last_commented_at: moment.utc(timestamp).format('YYYY-MM-DD HH:mm:ss')
185
+ }, {
186
+ id: memberId
187
+ });
188
+ }
189
+ }
190
+ }
191
+
192
+ module.exports = LastSeenAtUpdater;
@@ -13,7 +13,9 @@ class MembersEventsServiceWrapper {
13
13
  }
14
14
 
15
15
  // Wire up all the dependencies
16
- const {EventStorage, LastSeenAtUpdater, LastSeenAtCache} = require('@tryghost/members-events-service');
16
+ const EventStorage = require('./EventStorage');
17
+ const LastSeenAtUpdater = require('./LastSeenAtUpdater');
18
+ const LastSeenAtCache = require('./LastSeenAtCache');
17
19
  const models = require('../../models');
18
20
 
19
21
  // Listen for events and store them in the database
@@ -0,0 +1,117 @@
1
+ module.exports = class MentionEmailReportJob {
2
+ /** @type {IMentionReportGenerator} */
3
+ #mentionReportGenerator;
4
+
5
+ /** @type {IMentionReportRecipientRepository} */
6
+ #mentionReportRecipientRepository;
7
+
8
+ /** @type {IMentionReportEmailView} */
9
+ #mentionReportEmailView;
10
+
11
+ /** @type {IMentionReportHistoryService} */
12
+ #mentionReportHistoryService;
13
+
14
+ /** @type {IEmailService} */
15
+ #emailService;
16
+
17
+ /**
18
+ * @param {object} deps
19
+ * @param {IMentionReportGenerator} deps.mentionReportGenerator
20
+ * @param {IMentionReportRecipientRepository} deps.mentionReportRecipientRepository
21
+ * @param {IMentionReportEmailView} deps.mentionReportEmailView
22
+ * @param {IMentionReportHistoryService} deps.mentionReportHistoryService
23
+ * @param {IEmailService} deps.emailService
24
+ */
25
+ constructor(deps) {
26
+ this.#mentionReportGenerator = deps.mentionReportGenerator;
27
+ this.#mentionReportRecipientRepository = deps.mentionReportRecipientRepository;
28
+ this.#mentionReportEmailView = deps.mentionReportEmailView;
29
+ this.#mentionReportHistoryService = deps.mentionReportHistoryService;
30
+ this.#emailService = deps.emailService;
31
+ }
32
+
33
+ /**
34
+ * Checks for new mentions since the last report and sends an email to the recipients.
35
+ *
36
+ * @returns {Promise<number>} - A promise that resolves with the number of mentions found.
37
+ */
38
+ async sendLatestReport() {
39
+ const lastReport = await this.#mentionReportHistoryService.getLatestReportDate();
40
+ const now = new Date();
41
+
42
+ if (now.valueOf() - lastReport.valueOf() < 24 * 60 * 60 * 1000) {
43
+ return 0;
44
+ }
45
+
46
+ const report = await this.#mentionReportGenerator.getMentionReport(lastReport, now);
47
+
48
+ report.mentions = report.mentions.map((mention) => {
49
+ return {
50
+ targetUrl: mention.target,
51
+ sourceUrl: mention.source,
52
+ sourceTitle: mention.sourceTitle,
53
+ sourceExcerpt: mention.sourceExcerpt,
54
+ sourceSiteTitle: mention.sourceSiteTitle,
55
+ sourceFavicon: mention.sourceFavicon,
56
+ sourceAuthor: mention.sourceAuthor,
57
+ sourceFeaturedImage: mention.sourceFeaturedImage
58
+ };
59
+ });
60
+
61
+ if (!report?.mentions?.length) {
62
+ return 0;
63
+ }
64
+
65
+ const recipients = await this.#mentionReportRecipientRepository.getMentionReportRecipients();
66
+
67
+ for (const recipient of recipients) {
68
+ const subject = await this.#mentionReportEmailView.renderSubject(report, recipient);
69
+ const html = await this.#mentionReportEmailView.renderHTML(report, recipient);
70
+ const text = await this.#mentionReportEmailView.renderText(report, recipient);
71
+
72
+ await this.#emailService.send(recipient.email, subject, html, text);
73
+ }
74
+
75
+ await this.#mentionReportHistoryService.setLatestReportDate(now);
76
+
77
+ return report.mentions.length;
78
+ }
79
+ };
80
+
81
+ /**
82
+ * @typedef {object} MentionReportRecipient
83
+ * @prop {string} email
84
+ * @prop {string} slug
85
+ */
86
+
87
+ /**
88
+ * @typedef {object} IMentionReportRecipientRepository
89
+ * @prop {() => Promise<MentionReportRecipient[]>} getMentionReportRecipients
90
+ */
91
+
92
+ /**
93
+ * @typedef {import('@tryghost/webmentions/lib/MentionsAPI').MentionReport} MentionReport
94
+ */
95
+
96
+ /**
97
+ * @typedef {object} IMentionReportGenerator
98
+ * @prop {(startDate: Date, endDate: Date) => Promise<MentionReport>} getMentionReport
99
+ */
100
+
101
+ /**
102
+ * @typedef {object} IMentionReportEmailView
103
+ * @prop {(report: MentionReport, recipient: MentionReportRecipient) => Promise<string>} renderHTML
104
+ * @prop {(report: MentionReport, recipient: MentionReportRecipient) => Promise<string>} renderText
105
+ * @prop {(report: MentionReport, recipient: MentionReportRecipient) => Promise<string>} renderSubject
106
+ */
107
+
108
+ /**
109
+ * @typedef {object} IEmailService
110
+ * @prop {(to: string, subject: string, html: string, text: string) => Promise<void>} send
111
+ */
112
+
113
+ /**
114
+ * @typedef {object} IMentionReportHistoryService
115
+ * @prop {() => Promise<Date>} getLatestReportDate
116
+ * @prop {(date: Date) => Promise<void>} setLatestReportDate
117
+ */
@@ -1,8 +1,8 @@
1
- const MentionEmailReportJob = require('@tryghost/mentions-email-report');
1
+ const MentionEmailReportJob = require('./MentionEmailReportJob');
2
2
 
3
3
  /**
4
- * @typedef {import('@tryghost/mentions-email-report/lib/mentions-email-report').MentionReport} MentionReport
5
- * @typedef {import('@tryghost/mentions-email-report/lib/mentions-email-report').MentionReportRecipient} MentionReportRecipient
4
+ * @typedef {MentionEmailReportJob.MentionReport} MentionReport
5
+ * @typedef {MentionEmailReportJob.MentionReportRecipient} MentionReportRecipient
6
6
  */
7
7
 
8
8
  let initialised = false;
@@ -0,0 +1,179 @@
1
+ const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events');
2
+ const {MilestoneCreatedEvent} = require('@tryghost/milestones');
3
+
4
+ // @NOTE: 'StaffService' is a vague name that does not describe what it's actually doing.
5
+ // Possibly, "StaffNotificationService" or "StaffEventNotificationService" would be a more accurate name
6
+ class StaffService {
7
+ constructor({logging, models, mailer, settingsCache, settingsHelpers, urlUtils, blogIcon, DomainEvents, labs, memberAttributionService}) {
8
+ this.logging = logging;
9
+ this.labs = labs;
10
+ /** @private */
11
+ this.settingsCache = settingsCache;
12
+ this.models = models;
13
+ this.DomainEvents = DomainEvents;
14
+ this.memberAttributionService = memberAttributionService;
15
+
16
+ const Emails = require('./StaffServiceEmails');
17
+
18
+ this.emails = new Emails({
19
+ logging,
20
+ models,
21
+ mailer,
22
+ settingsHelpers,
23
+ settingsCache,
24
+ urlUtils,
25
+ blogIcon,
26
+ labs
27
+ });
28
+ }
29
+
30
+ /** @private */
31
+ getSerializedData({member, tier = null, subscription = null, offer = null}) {
32
+ return {
33
+ offer: offer ? {
34
+ name: offer.name,
35
+ type: offer.discount_type,
36
+ currency: offer.currency,
37
+ duration: offer.duration,
38
+ durationInMonths: offer.duration_in_months,
39
+ amount: offer.discount_amount
40
+ } : null,
41
+ subscription: subscription ? {
42
+ id: subscription.id,
43
+ amount: subscription.plan?.amount,
44
+ interval: subscription.plan?.interval,
45
+ currency: subscription.plan?.currency,
46
+ startDate: subscription.start_date,
47
+ cancelAt: subscription.current_period_end,
48
+ cancellationReason: subscription.cancellation_reason
49
+ } : null,
50
+ member: member ? {
51
+ id: member.id,
52
+ name: member.name,
53
+ email: member.email,
54
+ geolocation: member.geolocation,
55
+ status: member.status,
56
+ created_at: member.created_at
57
+ } : null,
58
+ tier: tier ? {
59
+ id: tier.id,
60
+ name: tier.name
61
+ } : null
62
+ };
63
+ }
64
+
65
+ /** @private */
66
+ async getDataFromIds({memberId, tierId = null, subscriptionId = null, offerId = null}) {
67
+ const memberModel = memberId ? await this.models.Member.findOne({id: memberId}) : null;
68
+ const tierModel = tierId ? await this.models.Product.findOne({id: tierId}) : null;
69
+ const subscriptionModel = subscriptionId ? await this.models.StripeCustomerSubscription.findOne({id: subscriptionId}) : null;
70
+ const offerModel = offerId ? await this.models.Offer.findOne({id: offerId}) : null;
71
+
72
+ return this.getSerializedData({
73
+ member: memberModel?.toJSON(),
74
+ tier: tierModel?.toJSON(),
75
+ subscription: subscriptionModel?.toJSON(),
76
+ offer: offerModel?.toJSON()
77
+ });
78
+ }
79
+
80
+ /** @private */
81
+ async handleEvent(type, event) {
82
+ if (type === MilestoneCreatedEvent && event.data.milestone) {
83
+ await this.emails.notifyMilestoneReceived(event.data);
84
+ }
85
+
86
+ if (!['api', 'member'].includes(event.data.source)) {
87
+ return;
88
+ }
89
+
90
+ const {member, tier, subscription, offer} = await this.getDataFromIds({
91
+ memberId: event.data.memberId,
92
+ tierId: event.data.tierId,
93
+ subscriptionId: event.data.subscriptionId,
94
+ offerId: event.data.offerId
95
+ });
96
+
97
+ if (type === MemberCreatedEvent && member.status === 'free') {
98
+ let attribution;
99
+ if (event.data?.attribution) {
100
+ attribution = await this.memberAttributionService.fetchResource(event.data.attribution);
101
+ } else {
102
+ try {
103
+ attribution = await this.memberAttributionService.getMemberCreatedAttribution(event.data.memberId);
104
+ } catch (e) {
105
+ this.logging.warn(`Failed to get attribution for member - ${event?.data?.memberId}`);
106
+ }
107
+ }
108
+ await this.emails.notifyFreeMemberSignup({
109
+ member,
110
+ attribution
111
+ });
112
+ } else if (type === SubscriptionActivatedEvent) {
113
+ let attribution;
114
+ if (event.data?.attribution) {
115
+ attribution = await this.memberAttributionService.fetchResource(event.data.attribution);
116
+ } else {
117
+ try {
118
+ attribution = await this.memberAttributionService.getSubscriptionCreatedAttribution(event.data.subscriptionId);
119
+ } catch (e) {
120
+ this.logging.warn(`Failed to get attribution for member - ${event?.data?.memberId}`);
121
+ }
122
+ }
123
+ await this.emails.notifyPaidSubscriptionStarted({
124
+ member,
125
+ offer,
126
+ tier,
127
+ subscription,
128
+ attribution
129
+ });
130
+ } else if (type === SubscriptionCancelledEvent) {
131
+ await this.emails.notifyPaidSubscriptionCanceled({
132
+ member,
133
+ tier,
134
+ subscription,
135
+ ...event.data
136
+ });
137
+ }
138
+ }
139
+
140
+ subscribeEvents() {
141
+ // Trigger email for free member signup
142
+ this.DomainEvents.subscribe(MemberCreatedEvent, async (event) => {
143
+ try {
144
+ await this.handleEvent(MemberCreatedEvent, event);
145
+ } catch (e) {
146
+ this.logging.error(e, `Failed to notify free member signup - ${event?.data?.memberId}`);
147
+ }
148
+ });
149
+
150
+ // Trigger email on paid subscription start
151
+ this.DomainEvents.subscribe(SubscriptionActivatedEvent, async (event) => {
152
+ try {
153
+ await this.handleEvent(SubscriptionActivatedEvent, event);
154
+ } catch (e) {
155
+ this.logging.error(e, `Failed to notify paid member subscription start - ${event?.data?.memberId}`);
156
+ }
157
+ });
158
+
159
+ // Trigger email when a member cancels their subscription
160
+ this.DomainEvents.subscribe(SubscriptionCancelledEvent, async (event) => {
161
+ try {
162
+ await this.handleEvent(SubscriptionCancelledEvent, event);
163
+ } catch (e) {
164
+ this.logging.error(e, `Failed to notify paid member subscription cancel - ${event?.data?.memberId}`);
165
+ }
166
+ });
167
+
168
+ // Trigger email when a new milestone is reached
169
+ this.DomainEvents.subscribe(MilestoneCreatedEvent, async (event) => {
170
+ try {
171
+ await this.handleEvent(MilestoneCreatedEvent, event);
172
+ } catch (e) {
173
+ this.logging.error(e, `Failed to notify milestone`);
174
+ }
175
+ });
176
+ }
177
+ }
178
+
179
+ module.exports = StaffService;