ghost 5.35.0 → 5.36.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 (154) hide show
  1. package/components/tryghost-adapter-cache-memory-ttl-5.36.0.tgz +0 -0
  2. package/components/tryghost-adapter-cache-redis-5.36.0.tgz +0 -0
  3. package/components/{tryghost-adapter-manager-5.35.0.tgz → tryghost-adapter-manager-5.36.0.tgz} +0 -0
  4. package/components/tryghost-api-framework-5.36.0.tgz +0 -0
  5. package/components/tryghost-api-version-compatibility-service-5.36.0.tgz +0 -0
  6. package/components/{tryghost-audience-feedback-5.35.0.tgz → tryghost-audience-feedback-5.36.0.tgz} +0 -0
  7. package/components/tryghost-bootstrap-socket-5.36.0.tgz +0 -0
  8. package/components/{tryghost-constants-5.35.0.tgz → tryghost-constants-5.36.0.tgz} +0 -0
  9. package/components/tryghost-custom-theme-settings-service-5.36.0.tgz +0 -0
  10. package/components/tryghost-data-generator-5.36.0.tgz +0 -0
  11. package/components/{tryghost-domain-events-5.35.0.tgz → tryghost-domain-events-5.36.0.tgz} +0 -0
  12. package/components/tryghost-dynamic-routing-events-5.36.0.tgz +0 -0
  13. package/components/tryghost-email-analytics-provider-mailgun-5.36.0.tgz +0 -0
  14. package/components/tryghost-email-analytics-service-5.36.0.tgz +0 -0
  15. package/components/{tryghost-email-content-generator-5.35.0.tgz → tryghost-email-content-generator-5.36.0.tgz} +0 -0
  16. package/components/{tryghost-email-events-5.35.0.tgz → tryghost-email-events-5.36.0.tgz} +0 -0
  17. package/components/tryghost-email-service-5.36.0.tgz +0 -0
  18. package/components/{tryghost-email-suppression-list-5.35.0.tgz → tryghost-email-suppression-list-5.36.0.tgz} +0 -0
  19. package/components/tryghost-event-aware-cache-wrapper-5.36.0.tgz +0 -0
  20. package/components/{tryghost-express-dynamic-redirects-5.35.0.tgz → tryghost-express-dynamic-redirects-5.36.0.tgz} +0 -0
  21. package/components/tryghost-extract-api-key-5.36.0.tgz +0 -0
  22. package/components/{tryghost-html-to-plaintext-5.35.0.tgz → tryghost-html-to-plaintext-5.36.0.tgz} +0 -0
  23. package/components/tryghost-i18n-5.36.0.tgz +0 -0
  24. package/components/tryghost-importer-revue-5.36.0.tgz +0 -0
  25. package/components/tryghost-job-manager-5.36.0.tgz +0 -0
  26. package/components/{tryghost-link-redirects-5.35.0.tgz → tryghost-link-redirects-5.36.0.tgz} +0 -0
  27. package/components/{tryghost-link-replacer-5.35.0.tgz → tryghost-link-replacer-5.36.0.tgz} +0 -0
  28. package/components/tryghost-link-tracking-5.36.0.tgz +0 -0
  29. package/components/{tryghost-magic-link-5.35.0.tgz → tryghost-magic-link-5.36.0.tgz} +0 -0
  30. package/components/tryghost-mailgun-client-5.36.0.tgz +0 -0
  31. package/components/{tryghost-member-attribution-5.35.0.tgz → tryghost-member-attribution-5.36.0.tgz} +0 -0
  32. package/components/{tryghost-member-events-5.35.0.tgz → tryghost-member-events-5.36.0.tgz} +0 -0
  33. package/components/tryghost-members-api-5.36.0.tgz +0 -0
  34. package/components/{tryghost-members-csv-5.35.0.tgz → tryghost-members-csv-5.36.0.tgz} +0 -0
  35. package/components/{tryghost-members-events-service-5.35.0.tgz → tryghost-members-events-service-5.36.0.tgz} +0 -0
  36. package/components/tryghost-members-importer-5.36.0.tgz +0 -0
  37. package/components/{tryghost-members-offers-5.35.0.tgz → tryghost-members-offers-5.36.0.tgz} +0 -0
  38. package/components/{tryghost-members-payments-5.35.0.tgz → tryghost-members-payments-5.36.0.tgz} +0 -0
  39. package/components/{tryghost-members-ssr-5.35.0.tgz → tryghost-members-ssr-5.36.0.tgz} +0 -0
  40. package/components/tryghost-members-stripe-service-5.36.0.tgz +0 -0
  41. package/components/tryghost-milestones-5.36.0.tgz +0 -0
  42. package/components/tryghost-minifier-5.36.0.tgz +0 -0
  43. package/components/tryghost-mw-api-version-mismatch-5.36.0.tgz +0 -0
  44. package/components/{tryghost-mw-cache-control-5.35.0.tgz → tryghost-mw-cache-control-5.36.0.tgz} +0 -0
  45. package/components/tryghost-mw-error-handler-5.36.0.tgz +0 -0
  46. package/components/tryghost-mw-session-from-token-5.36.0.tgz +0 -0
  47. package/components/{tryghost-mw-update-user-last-seen-5.35.0.tgz → tryghost-mw-update-user-last-seen-5.36.0.tgz} +0 -0
  48. package/components/tryghost-mw-vhost-5.36.0.tgz +0 -0
  49. package/components/tryghost-oembed-service-5.36.0.tgz +0 -0
  50. package/components/tryghost-package-json-5.36.0.tgz +0 -0
  51. package/components/tryghost-referrers-5.36.0.tgz +0 -0
  52. package/components/tryghost-security-5.36.0.tgz +0 -0
  53. package/components/tryghost-session-service-5.36.0.tgz +0 -0
  54. package/components/tryghost-settings-path-manager-5.36.0.tgz +0 -0
  55. package/components/tryghost-slack-notifications-5.36.0.tgz +0 -0
  56. package/components/tryghost-staff-service-5.36.0.tgz +0 -0
  57. package/components/tryghost-stats-service-5.36.0.tgz +0 -0
  58. package/components/tryghost-tiers-5.36.0.tgz +0 -0
  59. package/components/tryghost-update-check-service-5.36.0.tgz +0 -0
  60. package/components/tryghost-verification-trigger-5.36.0.tgz +0 -0
  61. package/components/{tryghost-version-notifications-data-service-5.35.0.tgz → tryghost-version-notifications-data-service-5.36.0.tgz} +0 -0
  62. package/components/tryghost-webmentions-5.36.0.tgz +0 -0
  63. package/core/built/admin/assets/{chunk.143.d49ad252968f2ef3966d.js → chunk.143.d5eaed4616c55cdbdabb.js} +6 -6
  64. package/core/built/admin/assets/{chunk.178.3d45fff87e08a5be5eb8.js → chunk.178.8cafcc33fe672738cc5b.js} +4 -4
  65. package/core/built/admin/assets/{chunk.502.c4afca88c98edad8b268.js → chunk.502.800e1515996bcc900013.js} +3 -3
  66. package/core/built/admin/assets/{chunk.79.ec143a398298020c87e6.js → chunk.79.53e8aa9671b2d5dae8ba.js} +1 -1
  67. package/core/built/admin/assets/codemirror/{codemirror-6c43f4894cbd8db73d7f35cde836c58e.js → codemirror-3f3b9966a7237652dd31484694e38ad5.js} +1 -1
  68. package/core/built/admin/assets/ghost-7ecf5c7934d90798485ee5ac2956f7fe.css +1 -0
  69. package/core/built/admin/assets/{ghost-4a6ed62455c9e367434183980b3ca3e9.js → ghost-b828e9e3c161aae92909c2e163656bb1.js} +295 -267
  70. package/core/built/admin/assets/ghost-dark-e50717df8e57d3e7fee67a0bcea895ad.css +1 -0
  71. package/core/built/admin/assets/img/mentions-background-fa39b7597e875c165b12190eda606993.png +0 -0
  72. package/core/built/admin/assets/simplemde/{simplemde-28049a9bd7f432b0648747eb26958a33.js → simplemde-9cd5549b68db674742d6ec2ecd72ac30.js} +1 -1
  73. package/core/built/admin/assets/vendor-c4684647d4f5213e5dbb6763de430e7e.js +22 -21
  74. package/core/built/admin/index.html +5 -5
  75. package/core/server/adapters/cache/MemoryTTL.js +3 -0
  76. package/core/server/api/endpoints/emails.js +35 -0
  77. package/core/server/api/endpoints/pages-public.js +1 -2
  78. package/core/server/api/endpoints/posts-public.js +2 -1
  79. package/core/server/api/endpoints/tags-public.js +2 -1
  80. package/core/server/api/endpoints/utils/serializers/input/index.js +4 -0
  81. package/core/server/api/endpoints/utils/serializers/input/mentions.js +11 -0
  82. package/core/server/api/endpoints/utils/serializers/output/emails.js +4 -0
  83. package/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +2 -5
  84. package/core/server/api/endpoints/utils/serializers/output/utils/clean.js +1 -0
  85. package/core/server/api/endpoints/utils/serializers/output/utils/extra-attrs.js +19 -11
  86. package/core/server/data/exporter/table-lists.js +2 -1
  87. package/core/server/data/migrations/versions/5.36/2023-02-20-12-22-add-milestones-table.js +10 -0
  88. package/core/server/data/migrations/versions/5.36/2023-02-21-12-29-add-milestone-notifications-column.js +7 -0
  89. package/core/server/data/migrations/versions/5.36/2023-02-23-10-40-set-outbound-link-tagging-based-on-source-tracking.js +31 -0
  90. package/core/server/data/schema/schema.js +9 -0
  91. package/core/server/lib/request-external.js +14 -13
  92. package/core/server/models/milestone.js +9 -0
  93. package/core/server/models/user.js +4 -1
  94. package/core/server/services/email-analytics/lib/queries.js +18 -3
  95. package/core/server/services/email-analytics/wrapper.js +34 -15
  96. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +2 -0
  97. package/core/server/services/mentions/BookshelfMentionRepository.js +2 -1
  98. package/core/server/services/mentions/ResourceService.js +6 -0
  99. package/core/server/services/mentions/RoutingService.js +2 -1
  100. package/core/server/services/mentions/service.js +1 -3
  101. package/core/server/services/milestones/BookshelfMilestoneRepository.js +136 -0
  102. package/core/server/services/milestones/MilestoneQueries.js +8 -3
  103. package/core/server/services/milestones/service.js +47 -9
  104. package/core/server/services/oembed/nft-oembed.js +1 -2
  105. package/core/server/services/posts-public/service.js +21 -9
  106. package/core/server/services/tags-public/service.js +21 -10
  107. package/core/server/services/websockets/service.js +2 -1
  108. package/core/server/web/api/endpoints/admin/routes.js +3 -0
  109. package/core/shared/config/defaults.json +5 -2
  110. package/core/shared/labs.js +5 -4
  111. package/package.json +125 -124
  112. package/yarn.lock +151 -199
  113. package/components/tryghost-adapter-cache-redis-5.35.0.tgz +0 -0
  114. package/components/tryghost-api-framework-5.35.0.tgz +0 -0
  115. package/components/tryghost-api-version-compatibility-service-5.35.0.tgz +0 -0
  116. package/components/tryghost-bootstrap-socket-5.35.0.tgz +0 -0
  117. package/components/tryghost-custom-theme-settings-service-5.35.0.tgz +0 -0
  118. package/components/tryghost-data-generator-5.35.0.tgz +0 -0
  119. package/components/tryghost-dynamic-routing-events-5.35.0.tgz +0 -0
  120. package/components/tryghost-email-analytics-provider-mailgun-5.35.0.tgz +0 -0
  121. package/components/tryghost-email-analytics-service-5.35.0.tgz +0 -0
  122. package/components/tryghost-email-service-5.35.0.tgz +0 -0
  123. package/components/tryghost-extract-api-key-5.35.0.tgz +0 -0
  124. package/components/tryghost-i18n-5.35.0.tgz +0 -0
  125. package/components/tryghost-importer-revue-5.35.0.tgz +0 -0
  126. package/components/tryghost-job-manager-5.35.0.tgz +0 -0
  127. package/components/tryghost-link-tracking-5.35.0.tgz +0 -0
  128. package/components/tryghost-mailgun-client-5.35.0.tgz +0 -0
  129. package/components/tryghost-members-api-5.35.0.tgz +0 -0
  130. package/components/tryghost-members-importer-5.35.0.tgz +0 -0
  131. package/components/tryghost-members-stripe-service-5.35.0.tgz +0 -0
  132. package/components/tryghost-milestones-5.35.0.tgz +0 -0
  133. package/components/tryghost-minifier-5.35.0.tgz +0 -0
  134. package/components/tryghost-mw-api-version-mismatch-5.35.0.tgz +0 -0
  135. package/components/tryghost-mw-error-handler-5.35.0.tgz +0 -0
  136. package/components/tryghost-mw-session-from-token-5.35.0.tgz +0 -0
  137. package/components/tryghost-mw-vhost-5.35.0.tgz +0 -0
  138. package/components/tryghost-oembed-service-5.35.0.tgz +0 -0
  139. package/components/tryghost-package-json-5.35.0.tgz +0 -0
  140. package/components/tryghost-public-resource-repository-5.35.0.tgz +0 -0
  141. package/components/tryghost-referrers-5.35.0.tgz +0 -0
  142. package/components/tryghost-security-5.35.0.tgz +0 -0
  143. package/components/tryghost-session-service-5.35.0.tgz +0 -0
  144. package/components/tryghost-settings-path-manager-5.35.0.tgz +0 -0
  145. package/components/tryghost-slack-notifications-5.35.0.tgz +0 -0
  146. package/components/tryghost-staff-service-5.35.0.tgz +0 -0
  147. package/components/tryghost-stats-service-5.35.0.tgz +0 -0
  148. package/components/tryghost-tiers-5.35.0.tgz +0 -0
  149. package/components/tryghost-update-check-service-5.35.0.tgz +0 -0
  150. package/components/tryghost-verification-trigger-5.35.0.tgz +0 -0
  151. package/components/tryghost-webmentions-5.35.0.tgz +0 -0
  152. package/core/built/admin/assets/ghost-558c1e319d6e025bfab2054bc0f7fe83.css +0 -1
  153. package/core/built/admin/assets/ghost-dark-a15754df1f9070dc2525482ce22e2251.css +0 -1
  154. /package/core/built/admin/assets/{chunk.502.c4afca88c98edad8b268.js.LICENSE.txt → chunk.502.800e1515996bcc900013.js.LICENSE.txt} +0 -0
@@ -15,9 +15,24 @@ module.exports = {
15
15
  const startDate = new Date();
16
16
 
17
17
  // three separate queries is much faster than using max/greatest (with coalesce to handle nulls) across columns
18
- const {maxDeliveredAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(delivered_at) as maxDeliveredAt')).first() || {};
19
- const {maxOpenedAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(opened_at) as maxOpenedAt')).first() || {};
20
- const {maxFailedAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(failed_at) as maxFailedAt')).first() || {};
18
+ let {maxDeliveredAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(delivered_at) as maxDeliveredAt')).first() || {};
19
+ let {maxOpenedAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(opened_at) as maxOpenedAt')).first() || {};
20
+ let {maxFailedAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(failed_at) as maxFailedAt')).first() || {};
21
+
22
+ if (maxDeliveredAt && !(maxDeliveredAt instanceof Date)) {
23
+ // SQLite returns a string instead of a Date
24
+ maxDeliveredAt = new Date(maxDeliveredAt);
25
+ }
26
+
27
+ if (maxOpenedAt && !(maxOpenedAt instanceof Date)) {
28
+ // SQLite returns a string instead of a Date
29
+ maxOpenedAt = new Date(maxOpenedAt);
30
+ }
31
+
32
+ if (maxFailedAt && !(maxFailedAt instanceof Date)) {
33
+ // SQLite returns a string instead of a Date
34
+ maxFailedAt = new Date(maxFailedAt);
35
+ }
21
36
 
22
37
  const lastSeenEventTimestamp = _.max([maxDeliveredAt, maxOpenedAt, maxFailedAt]);
23
38
  debug(`getLastSeenEventTimestamp: finished in ${Date.now() - startDate}ms`);
@@ -1,5 +1,4 @@
1
1
  const logging = require('@tryghost/logging');
2
- const debug = require('@tryghost/debug')('jobs:email-analytics:fetch-latest');
3
2
 
4
3
  class EmailAnalyticsServiceWrapper {
5
4
  init() {
@@ -55,22 +54,40 @@ class EmailAnalyticsServiceWrapper {
55
54
  });
56
55
  }
57
56
 
58
- async fetchLatest() {
57
+ async fetchLatest({maxEvents} = {maxEvents: Infinity}) {
58
+ logging.info('[EmailAnalytics] Fetch latest started');
59
+
59
60
  const fetchStartDate = new Date();
60
- debug('Starting email analytics fetch of latest events');
61
- const eventStats = await this.service.fetchLatest();
61
+ const totalEvents = await this.service.fetchLatest({maxEvents});
62
62
  const fetchEndDate = new Date();
63
- debug(`Finished fetching ${eventStats.totalEvents} analytics events in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms`);
64
63
 
65
- const aggregateStartDate = new Date();
66
- debug(`Starting email analytics aggregation for ${eventStats.emailIds.length} emails`);
67
- await this.service.aggregateStats(eventStats);
68
- const aggregateEndDate = new Date();
69
- debug(`Finished aggregating email analytics in ${aggregateEndDate.getTime() - aggregateStartDate.getTime()}ms`);
64
+ logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (latest)`);
65
+ return totalEvents;
66
+ }
70
67
 
71
- logging.info(`Fetched ${eventStats.totalEvents} events and aggregated stats for ${eventStats.emailIds.length} emails in ${aggregateEndDate.getTime() - fetchStartDate.getTime()}ms`);
68
+ async fetchMissing({maxEvents} = {maxEvents: Infinity}) {
69
+ logging.info('[EmailAnalytics] Fetch missing started');
72
70
 
73
- return eventStats;
71
+ const fetchStartDate = new Date();
72
+ const totalEvents = await this.service.fetchMissing({maxEvents});
73
+ const fetchEndDate = new Date();
74
+
75
+ logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (missing)`);
76
+ return totalEvents;
77
+ }
78
+
79
+ async fetchScheduled({maxEvents}) {
80
+ if (maxEvents < 300) {
81
+ return 0;
82
+ }
83
+ logging.info('[EmailAnalytics] Fetch scheduled started');
84
+
85
+ const fetchStartDate = new Date();
86
+ const totalEvents = await this.service.fetchScheduled({maxEvents});
87
+ const fetchEndDate = new Date();
88
+
89
+ logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (scheduled)`);
90
+ return totalEvents;
74
91
  }
75
92
 
76
93
  async startFetch() {
@@ -80,12 +97,14 @@ class EmailAnalyticsServiceWrapper {
80
97
  }
81
98
  this.fetching = true;
82
99
 
83
- logging.info('Email analytics fetch started');
84
100
  try {
85
- const eventStats = await this.fetchLatest();
101
+ const c1 = await this.fetchLatest({maxEvents: Infinity});
102
+ const c2 = await this.fetchMissing({maxEvents: Infinity});
103
+
104
+ // Only fetch scheduled if we didn't fetch a lot of normal events
105
+ await this.fetchScheduled({maxEvents: 20000 - c1 - c2});
86
106
 
87
107
  this.fetching = false;
88
- return eventStats;
89
108
  } catch (e) {
90
109
  logging.error(e, 'Error while fetching email analytics');
91
110
 
@@ -2,6 +2,7 @@ const {AbstractEmailSuppressionList, EmailSuppressionData, EmailSuppressedEvent}
2
2
  const {SpamComplaintEvent, EmailBouncedEvent} = require('@tryghost/email-events');
3
3
  const DomainEvents = require('@tryghost/domain-events');
4
4
  const logging = require('@tryghost/logging');
5
+ const models = require('../../models');
5
6
 
6
7
  /**
7
8
  * @typedef {object} IMailgunAPIClient
@@ -95,6 +96,7 @@ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
95
96
  }
96
97
 
97
98
  async init() {
99
+ this.Suppression = models.Suppression;
98
100
  const handleEvent = reason => async (event) => {
99
101
  try {
100
102
  if (reason === 'bounce') {
@@ -49,6 +49,7 @@ module.exports = class BookshelfMentionRepository {
49
49
  timestamp: model.get('created_at'),
50
50
  payload,
51
51
  resourceId: model.get('resource_id'),
52
+ resourceType: model.get('resource_type'),
52
53
  sourceTitle: model.get('source_title'),
53
54
  sourceSiteTitle: model.get('source_site_title'),
54
55
  sourceAuthor: model.get('source_author'),
@@ -106,7 +107,7 @@ module.exports = class BookshelfMentionRepository {
106
107
  source_favicon: mention.sourceFavicon?.href,
107
108
  target: mention.target.href,
108
109
  resource_id: mention.resourceId?.toHexString(),
109
- resource_type: mention.resourceId ? 'post' : null,
110
+ resource_type: mention.resourceType,
110
111
  payload: mention.payload ? JSON.stringify(mention.payload) : null,
111
112
  deleted: Mention.isDeleted(mention),
112
113
  verified: mention.verified
@@ -37,6 +37,12 @@ module.exports = class ResourceService {
37
37
  id: ObjectID.createFromHexString(resource.data.id)
38
38
  };
39
39
  }
40
+ if (resource?.config?.type === 'pages') {
41
+ return {
42
+ type: 'page',
43
+ id: ObjectID.createFromHexString(resource.data.id)
44
+ };
45
+ }
40
46
  return {
41
47
  type: null,
42
48
  id: null
@@ -56,7 +56,8 @@ module.exports = class RoutingService {
56
56
 
57
57
  try {
58
58
  const response = await this.#externalRequest.head(url, {
59
- followRedirect: false
59
+ followRedirect: false,
60
+ throwHttpErrors: false
60
61
  });
61
62
  if (response.statusCode < 400 && response.statusCode > 199) {
62
63
  return true;
@@ -70,17 +70,15 @@ module.exports = {
70
70
  if (!id) {
71
71
  return null;
72
72
  }
73
-
74
73
  const post = await models.Post.findOne({id: id.toHexString()});
75
74
 
76
75
  if (!post) {
77
76
  return null;
78
77
  }
79
-
80
78
  return {
81
79
  id: id,
82
80
  name: post.get('title'),
83
- type: 'post'
81
+ type: post.get('type')
84
82
  };
85
83
  }
86
84
  }
@@ -0,0 +1,136 @@
1
+ const {Milestone} = require('@tryghost/milestones');
2
+
3
+ /**
4
+ * @typedef {import('@tryghost/milestones/lib/MilestonesService').IMilestoneRepository} IMilestoneRepository
5
+ * @typedef {import('@tryghost/milestones/lib/MilestonesService')} Milestone
6
+ */
7
+
8
+ /**
9
+ * @implements {IMilestoneRepository}
10
+ */
11
+ module.exports = class BookshelfMilestoneRepository {
12
+ /** @type {Object} */
13
+ #MilestoneModel;
14
+
15
+ /** @type {import('@tryghost/domain-events')} */
16
+ #DomainEvents;
17
+
18
+ /**
19
+ * @param {object} deps
20
+ * @param {object} deps.MilestoneModel Bookshelf Model
21
+ * @param {import('@tryghost/domain-events')} deps.DomainEvents
22
+ */
23
+ constructor(deps) {
24
+ this.#MilestoneModel = deps.MilestoneModel;
25
+ this.#DomainEvents = deps.DomainEvents;
26
+ }
27
+
28
+ #modelToMilestone(model) {
29
+ return Milestone.create({
30
+ id: model.get('id'),
31
+ type: model.get('type'),
32
+ value: model.get('value'),
33
+ currency: model.get('currency'),
34
+ createdAt: model.get('created_at'),
35
+ emailSentAt: model.get('email_sent_at')
36
+ });
37
+ }
38
+
39
+ /**
40
+ * @param {import('@tryghost/milestones/lib/Milestone')} milestone
41
+ * @returns {Promise<void>}
42
+ */
43
+ async save(milestone) {
44
+ const data = {
45
+ id: milestone.id.toHexString(),
46
+ type: milestone.type,
47
+ value: milestone.value,
48
+ currency: milestone?.currency,
49
+ created_at: milestone?.createdAt,
50
+ email_sent_at: milestone?.emailSentAt
51
+ };
52
+
53
+ const existing = await this.#MilestoneModel.findOne({id: data.id}, {require: false});
54
+
55
+ if (!existing) {
56
+ await this.#MilestoneModel.add(data);
57
+ } else {
58
+ await this.#MilestoneModel.edit(data, {
59
+ id: data.id
60
+ });
61
+ }
62
+ for (const event of milestone.events) {
63
+ this.#DomainEvents.dispatch(event);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * @param {'arr'|'members'} type
69
+ * @param {string} [currency]
70
+ *
71
+ * @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
72
+ */
73
+ async getLatestByType(type, currency = 'usd') {
74
+ let milestone = null;
75
+
76
+ if (type === 'arr') {
77
+ milestone = await this.#MilestoneModel.findAll({filter: `currency:${currency}+type:arr`, order: 'created_at ASC, value DESC'}, {require: false});
78
+ } else {
79
+ milestone = await this.#MilestoneModel.findAll({filter: 'type:members', order: 'created_at ASC, value DESC'}, {require: false});
80
+ }
81
+
82
+ if (!milestone || !milestone?.models?.length) {
83
+ return null;
84
+ } else {
85
+ milestone = milestone.models?.[0];
86
+ }
87
+
88
+ return this.#modelToMilestone(milestone);
89
+ }
90
+
91
+ /**
92
+ * @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
93
+ */
94
+ async getLastEmailSent() {
95
+ let milestone = await this.#MilestoneModel.findAll({filter: 'email_sent_at:-null', order: 'email_sent_at ASC'}, {require: false});
96
+
97
+ if (!milestone || !milestone?.models?.length) {
98
+ return null;
99
+ } else {
100
+ milestone = milestone.models?.[0];
101
+ }
102
+
103
+ return this.#modelToMilestone(milestone);
104
+ }
105
+
106
+ /**
107
+ * @param {number} value
108
+ * @param {string} [currency]
109
+ *
110
+ * @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
111
+ */
112
+ async getByARR(value, currency = 'usd') {
113
+ // find a milestone of the ARR type by a given value
114
+ const milestone = await this.#MilestoneModel.findOne({type: 'arr', currency: currency, value: value}, {require: false});
115
+
116
+ if (!milestone) {
117
+ return null;
118
+ }
119
+ return this.#modelToMilestone(milestone);
120
+ }
121
+
122
+ /**
123
+ * @param {number} value
124
+ *
125
+ * @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
126
+ */
127
+ async getByCount(value) {
128
+ // find a milestone of the members type by a given value
129
+ const milestone = await this.#MilestoneModel.findOne({type: 'members', value: value}, {require: false});
130
+
131
+ if (!milestone) {
132
+ return null;
133
+ }
134
+ return this.#modelToMilestone(milestone);
135
+ }
136
+ };
@@ -1,10 +1,12 @@
1
- const MIN_DAYS_SINCE_IMPORTED = 7;
2
-
3
1
  module.exports = class MilestoneQueries {
4
2
  #db;
5
3
 
4
+ /** @type {number} */
5
+ #minDaysSinceImported;
6
+
6
7
  constructor(deps) {
7
8
  this.#db = deps.db;
9
+ this.#minDaysSinceImported = deps.minDaysSinceImported;
8
10
  }
9
11
 
10
12
  /**
@@ -31,10 +33,13 @@ module.exports = class MilestoneQueries {
31
33
  * @returns {Promise<boolean>}
32
34
  */
33
35
  async hasImportedMembersInPeriod() {
36
+ const importedThreshold = new Date();
37
+ importedThreshold.setDate(importedThreshold.getDate() - this.#minDaysSinceImported);
38
+
34
39
  const [hasImportedMembers] = await this.#db.knex('members_subscribe_events')
35
40
  .count('id as count')
36
41
  .where('source', '=', 'import')
37
- .where('created_at', '>=', MIN_DAYS_SINCE_IMPORTED);
42
+ .where('created_at', '>=', importedThreshold);
38
43
 
39
44
  return hasImportedMembers?.count > 0;
40
45
  }
@@ -1,4 +1,9 @@
1
1
  const DomainEvents = require('@tryghost/domain-events');
2
+ const logging = require('@tryghost/logging');
3
+ const models = require('../../models');
4
+ const BookshelfMilestoneRepository = require('./BookshelfMilestoneRepository');
5
+
6
+ const JOB_TIMEOUT = 1000 * 60 * 60 * 24 * (Math.floor(Math.random() * 4)); // 0 - 4 days;
2
7
 
3
8
  const getStripeLiveEnabled = () => {
4
9
  const settingsCache = require('../../../shared/settings-cache');
@@ -28,19 +33,23 @@ module.exports = {
28
33
  const db = require('../../data/db');
29
34
  const MilestoneQueries = require('./MilestoneQueries');
30
35
 
31
- const {
32
- MilestonesService,
33
- InMemoryMilestoneRepository
34
- } = require('@tryghost/milestones');
36
+ const {MilestonesService} = require('@tryghost/milestones');
35
37
  const config = require('../../../shared/config');
36
38
  const milestonesConfig = config.get('milestones');
37
39
 
38
- const repository = new InMemoryMilestoneRepository({DomainEvents});
39
- const queries = new MilestoneQueries({db});
40
+ const repository = new BookshelfMilestoneRepository({
41
+ DomainEvents,
42
+ MilestoneModel: models.Milestone
43
+ });
44
+
45
+ const queries = new MilestoneQueries({
46
+ db,
47
+ minDaysSinceImported: milestonesConfig?.minDaysSinceImported || 7
48
+ });
40
49
 
41
50
  this.api = new MilestonesService({
42
51
  repository,
43
- milestonesConfig, // avoid using getters and pass as JSON
52
+ milestonesConfig,
44
53
  queries
45
54
  });
46
55
  }
@@ -69,10 +78,39 @@ module.exports = {
69
78
  },
70
79
 
71
80
  /**
81
+ *
82
+ * @param {number} [customTimeout]
83
+ *
84
+ * @returns {Promise<object>}
85
+ */
86
+ async scheduleRun(customTimeout) {
87
+ const timeOut = customTimeout || JOB_TIMEOUT;
88
+
89
+ const today = new Date();
90
+ const msNow = today.getMilliseconds();
91
+ const newMs = msNow + timeOut;
92
+ const jobDate = today.setMilliseconds(newMs);
93
+
94
+ logging.info(`Running milestone emails job on ${new Date(jobDate).toString()}`);
95
+
96
+ return new Promise((resolve) => {
97
+ setTimeout(async () => {
98
+ const result = await this.run();
99
+ return resolve(result);
100
+ }, timeOut);
101
+ });
102
+ },
103
+
104
+ /**
105
+ * @param {number} [customTimeout]
106
+ * Only used temporary for testing purposes.
107
+ * Will be removed, after job scheduling implementation.
108
+ *
72
109
  * @returns {Promise<object>}
73
110
  */
74
- async initAndRun() {
111
+ async initAndRun(customTimeout) {
75
112
  await this.init();
76
- return await this.run();
113
+
114
+ return this.scheduleRun(customTimeout);
77
115
  }
78
116
  };
@@ -42,9 +42,8 @@ class NFTOEmbedProvider {
42
42
  headers['X-API-KEY'] = this.dependencies.config.apiKey;
43
43
  }
44
44
  const result = await externalRequest(`https://api.opensea.io/api/v1/asset/${transaction}/${asset}/?format=json`, {
45
- json: true,
46
45
  headers
47
- });
46
+ }).json();
48
47
  return {
49
48
  version: '1.0',
50
49
  type: 'nft',
@@ -6,24 +6,36 @@ class PostsPublicServiceWrapper {
6
6
  }
7
7
 
8
8
  // Wire up all the dependencies
9
- const {Post} = require('../../models');
10
9
  const adapterManager = require('../adapter-manager');
11
10
  const config = require('../../../shared/config');
11
+ const EventAwareCacheWrapper = require('@tryghost/event-aware-cache-wrapper');
12
+ const EventRegistry = require('../../lib/common/events');
12
13
 
13
14
  let postsCache;
14
15
  if (config.get('hostSettings:postsPublicCache:enabled')) {
15
- postsCache = adapterManager.getAdapter('cache:postsPublic');
16
+ const cache = adapterManager.getAdapter('cache:postsPublic');
17
+ postsCache = new EventAwareCacheWrapper({
18
+ cache: cache,
19
+ resetEvents: ['site.changed'],
20
+ eventRegistry: EventRegistry
21
+ });
16
22
  }
17
23
 
18
- const {PublicResourcesRepository} = require('@tryghost/public-resource-repository');
19
-
20
- this.postsRepository = new PublicResourcesRepository({
21
- Model: Post,
22
- cache: postsCache
23
- });
24
+ let cache;
25
+ if (postsCache) {
26
+ // @NOTE: exposing cache through getter and setter to not loose the context of "this"
27
+ cache = {
28
+ get() {
29
+ return postsCache.get(...arguments);
30
+ },
31
+ set() {
32
+ return postsCache.set(...arguments);
33
+ }
34
+ };
35
+ }
24
36
 
25
37
  this.api = {
26
- browse: this.postsRepository.getAll.bind(this.postsRepository)
38
+ cache: cache
27
39
  };
28
40
  }
29
41
  }
@@ -6,24 +6,35 @@ class TagsPublicServiceWrapper {
6
6
  }
7
7
 
8
8
  // Wire up all the dependencies
9
- const {TagPublic} = require('../../models');
10
9
  const adapterManager = require('../adapter-manager');
11
10
  const config = require('../../../shared/config');
11
+ const EventAwareCacheWrapper = require('@tryghost/event-aware-cache-wrapper');
12
+ const EventRegistry = require('../../lib/common/events');
12
13
 
13
14
  let tagsCache;
14
15
  if (config.get('hostSettings:tagsPublicCache:enabled')) {
15
- tagsCache = adapterManager.getAdapter('cache:tagsPublic');
16
+ let tagsPublicCache = adapterManager.getAdapter('cache:tagsPublic');
17
+ tagsCache = new EventAwareCacheWrapper({
18
+ cache: tagsPublicCache,
19
+ resetEvents: ['site.changed'],
20
+ eventRegistry: EventRegistry
21
+ });
16
22
  }
17
23
 
18
- const {PublicResourcesRepository} = require('@tryghost/public-resource-repository');
19
-
20
- this.tagsPublicRepository = new PublicResourcesRepository({
21
- Model: TagPublic,
22
- cache: tagsCache
23
- });
24
-
24
+ let cache;
25
+ if (tagsCache) {
26
+ // @NOTE: exposing cache through getter and setter to not loose the context of "this"
27
+ cache = {
28
+ get() {
29
+ return tagsCache.get(...arguments);
30
+ },
31
+ set() {
32
+ return tagsCache.set(...arguments);
33
+ }
34
+ };
35
+ }
25
36
  this.api = {
26
- browse: this.tagsPublicRepository.getAll.bind(this.tagsPublicRepository)
37
+ cache: cache
27
38
  };
28
39
  }
29
40
  }
@@ -15,7 +15,8 @@ module.exports = {
15
15
  let count = 0;
16
16
 
17
17
  io.on(`connection`, (socket) => {
18
- debug(`[Websockets] Client connected`);
18
+ logging.info(`Websockets client connected (id: ${socket.id})`);
19
+
19
20
  // on connect, send current value
20
21
  socket.emit('addCount', count);
21
22
  // listen to to changes in value from client
@@ -300,6 +300,9 @@ module.exports = function apiRoutes() {
300
300
  router.put('/emails/:id/retry', mw.authAdminApi, http(api.emails.retry));
301
301
  router.get('/emails/:id/batches', mw.authAdminApi, http(api.emails.browseBatches));
302
302
  router.get('/emails/:id/recipient-failures', mw.authAdminApi, http(api.emails.browseFailures));
303
+ router.get('/emails/:id/analytics', mw.authAdminApi, http(api.emails.analyticsStatus));
304
+ router.put('/emails/:id/analytics', mw.authAdminApi, http(api.emails.scheduleAnalytics));
305
+ router.delete('/emails/analytics', mw.authAdminApi, http(api.emails.cancelScheduledAnalytics));
303
306
 
304
307
  // ## Snippets
305
308
  router.get('/snippets', mw.authAdminApi, http(api.snippets.browse));
@@ -176,7 +176,7 @@
176
176
  },
177
177
  "portal": {
178
178
  "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
179
- "version": "2.24"
179
+ "version": "2.25"
180
180
  },
181
181
  "sodoSearch": {
182
182
  "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
@@ -213,6 +213,9 @@
213
213
  "values": [100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
214
214
  }
215
215
  ],
216
- "members": [100, 1000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000]
216
+ "members": [100, 1000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000],
217
+ "minDaysSinceImported": 7,
218
+ "minDaysSinceLastEmail": 14,
219
+ "maxPercentageFromMilestone": 0.1
217
220
  }
218
221
  }
@@ -20,7 +20,9 @@ const GA_FEATURES = [
20
20
  'memberAttribution',
21
21
  'audienceFeedback',
22
22
  'themeErrorsNotification',
23
- 'emailStability'
23
+ 'emailStability',
24
+ 'emailErrors',
25
+ 'outboundLinkTagging'
24
26
  ];
25
27
 
26
28
  // NOTE: this allowlist is meant to be used to filter out any unexpected
@@ -28,7 +30,6 @@ const GA_FEATURES = [
28
30
  const BETA_FEATURES = [
29
31
  'activitypub',
30
32
  'webmentions',
31
- 'emailErrors',
32
33
  'milestoneEmails'
33
34
  ];
34
35
 
@@ -36,8 +37,8 @@ const ALPHA_FEATURES = [
36
37
  'urlCache',
37
38
  'beforeAfterCard',
38
39
  'lexicalEditor',
39
- 'outboundLinkTagging',
40
- 'websockets'
40
+ 'websockets',
41
+ 'webmentionEmails'
41
42
  ];
42
43
 
43
44
  module.exports.GA_KEYS = [...GA_FEATURES];