ghost 5.19.3 → 5.21.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 (180) hide show
  1. package/components/tryghost-adapter-manager-5.21.0.tgz +0 -0
  2. package/components/{tryghost-api-framework-5.19.3.tgz → tryghost-api-framework-5.21.0.tgz} +0 -0
  3. package/components/tryghost-api-version-compatibility-service-5.21.0.tgz +0 -0
  4. package/components/tryghost-audience-feedback-5.21.0.tgz +0 -0
  5. package/components/tryghost-bootstrap-socket-5.21.0.tgz +0 -0
  6. package/components/tryghost-constants-5.21.0.tgz +0 -0
  7. package/components/tryghost-custom-theme-settings-service-5.21.0.tgz +0 -0
  8. package/components/tryghost-data-generator-5.21.0.tgz +0 -0
  9. package/components/tryghost-domain-events-5.21.0.tgz +0 -0
  10. package/components/tryghost-email-analytics-provider-mailgun-5.21.0.tgz +0 -0
  11. package/components/tryghost-email-analytics-service-5.21.0.tgz +0 -0
  12. package/components/tryghost-email-content-generator-5.21.0.tgz +0 -0
  13. package/components/tryghost-express-dynamic-redirects-5.21.0.tgz +0 -0
  14. package/components/tryghost-extract-api-key-5.21.0.tgz +0 -0
  15. package/components/tryghost-html-to-plaintext-5.21.0.tgz +0 -0
  16. package/components/{tryghost-job-manager-5.19.3.tgz → tryghost-job-manager-5.21.0.tgz} +0 -0
  17. package/components/tryghost-link-redirects-5.21.0.tgz +0 -0
  18. package/components/tryghost-link-replacer-5.21.0.tgz +0 -0
  19. package/components/tryghost-link-tracking-5.21.0.tgz +0 -0
  20. package/components/tryghost-magic-link-5.21.0.tgz +0 -0
  21. package/components/tryghost-mailgun-client-5.21.0.tgz +0 -0
  22. package/components/tryghost-member-analytics-service-5.21.0.tgz +0 -0
  23. package/components/tryghost-member-attribution-5.21.0.tgz +0 -0
  24. package/components/tryghost-member-events-5.21.0.tgz +0 -0
  25. package/components/tryghost-members-analytics-ingress-5.21.0.tgz +0 -0
  26. package/components/tryghost-members-api-5.21.0.tgz +0 -0
  27. package/components/tryghost-members-csv-5.21.0.tgz +0 -0
  28. package/components/tryghost-members-events-service-5.21.0.tgz +0 -0
  29. package/components/tryghost-members-importer-5.21.0.tgz +0 -0
  30. package/components/tryghost-members-offers-5.21.0.tgz +0 -0
  31. package/components/tryghost-members-payments-5.21.0.tgz +0 -0
  32. package/components/{tryghost-members-ssr-5.19.3.tgz → tryghost-members-ssr-5.21.0.tgz} +0 -0
  33. package/components/tryghost-members-stripe-service-5.21.0.tgz +0 -0
  34. package/components/tryghost-minifier-5.21.0.tgz +0 -0
  35. package/components/tryghost-mw-api-version-mismatch-5.21.0.tgz +0 -0
  36. package/components/tryghost-mw-cache-control-5.21.0.tgz +0 -0
  37. package/components/tryghost-mw-error-handler-5.21.0.tgz +0 -0
  38. package/components/tryghost-mw-session-from-token-5.21.0.tgz +0 -0
  39. package/components/tryghost-mw-update-user-last-seen-5.21.0.tgz +0 -0
  40. package/components/tryghost-mw-vhost-5.21.0.tgz +0 -0
  41. package/components/tryghost-oembed-service-5.21.0.tgz +0 -0
  42. package/components/{tryghost-package-json-5.19.3.tgz → tryghost-package-json-5.21.0.tgz} +0 -0
  43. package/components/tryghost-referrers-5.21.0.tgz +0 -0
  44. package/components/tryghost-security-5.21.0.tgz +0 -0
  45. package/components/tryghost-session-service-5.21.0.tgz +0 -0
  46. package/components/tryghost-settings-path-manager-5.21.0.tgz +0 -0
  47. package/components/tryghost-staff-service-5.21.0.tgz +0 -0
  48. package/components/tryghost-stats-service-5.21.0.tgz +0 -0
  49. package/components/tryghost-tiers-5.21.0.tgz +0 -0
  50. package/components/{tryghost-update-check-service-5.19.3.tgz → tryghost-update-check-service-5.21.0.tgz} +0 -0
  51. package/components/tryghost-verification-trigger-5.21.0.tgz +0 -0
  52. package/components/tryghost-version-notifications-data-service-5.21.0.tgz +0 -0
  53. package/core/boot.js +2 -0
  54. package/core/built/admin/assets/{chunk.143.c035c61595ed02eee886.js → chunk.143.9cddfa7bd1a8b9cf3d4b.js} +7 -7
  55. package/core/built/admin/assets/{chunk.178.998dfbcebcec635146b1.js → chunk.178.6de14cfdb28df721b66e.js} +4 -4
  56. package/core/built/admin/assets/{chunk.613.f1d519ad47e7f9024263.js → chunk.613.695f31829550fb00d43c.js} +352 -421
  57. package/core/built/admin/assets/{chunk.613.f1d519ad47e7f9024263.js.LICENSE.txt → chunk.613.695f31829550fb00d43c.js.LICENSE.txt} +0 -0
  58. package/core/built/admin/assets/{ghost-5ce6f5a730c83c91fc258b12c537ea35.js → ghost-192fee3b46a193df1e65c49a67a7d694.js} +2866 -2707
  59. package/core/built/admin/assets/ghost-9873519a8ad69b5b23284f0a9e050bc6.css +1 -0
  60. package/core/built/admin/assets/ghost-dark-190bdce42b125c3d4be930bd7599b442.css +1 -0
  61. package/core/built/admin/assets/{vendor-5c7d7063620bec13668c4370145cd4b4.js → vendor-26cca1d4d56660dc6e915a12ccc3b330.js} +1079 -1032
  62. package/core/built/admin/index.html +7 -7
  63. package/core/cli/generate-data.js +51 -0
  64. package/core/frontend/helpers/ghost_head.js +1 -1
  65. package/core/server/api/endpoints/links.js +34 -1
  66. package/core/server/api/endpoints/members.js +1 -4
  67. package/core/server/api/endpoints/posts-public.js +1 -1
  68. package/core/server/api/endpoints/posts.js +2 -1
  69. package/core/server/api/endpoints/tiers-public.js +2 -14
  70. package/core/server/api/endpoints/tiers.js +5 -51
  71. package/core/server/api/endpoints/utils/serializers/input/posts.js +21 -1
  72. package/core/server/api/endpoints/utils/serializers/input/settings.js +1 -0
  73. package/core/server/api/endpoints/utils/serializers/input/tiers.js +18 -27
  74. package/core/server/api/endpoints/utils/serializers/output/index.js +4 -0
  75. package/core/server/api/endpoints/utils/serializers/output/links.js +5 -0
  76. package/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js +89 -15
  77. package/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +20 -6
  78. package/core/server/api/endpoints/utils/serializers/output/mappers/snippets.js +2 -2
  79. package/core/server/api/endpoints/utils/serializers/output/members.js +6 -5
  80. package/core/server/api/endpoints/utils/serializers/output/tiers.js +15 -55
  81. package/core/server/data/db/backup.js +17 -10
  82. package/core/server/data/importer/importers/data/custom-theme-settings.js +81 -0
  83. package/core/server/data/importer/importers/data/data-importer.js +2 -0
  84. package/core/server/data/migrations/utils/permissions.js +35 -24
  85. package/core/server/data/migrations/versions/5.20/2022-10-18-05-39-drop-nullable-tier-id.js +3 -0
  86. package/core/server/data/migrations/versions/5.20/2022-10-18-10-13-add-ghost-subscription-id-column-to-mscs.js +10 -0
  87. package/core/server/data/migrations/versions/5.20/2022-10-19-11-17-add-link-browse-permissions.js +10 -0
  88. package/core/server/data/migrations/versions/5.20/2022-10-20-02-52-add-link-edit-permissions.js +10 -0
  89. package/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js +20 -0
  90. package/core/server/data/migrations/versions/5.21/2022-10-25-12-05-backfill-missed-products-columns.js +35 -0
  91. package/core/server/data/migrations/versions/5.21/2022-10-26-04-49-add-batch-id-members-created-events.js +7 -0
  92. package/core/server/data/migrations/versions/5.21/2022-10-26-04-49-add-batch-id-subscription-created-events.js +7 -0
  93. package/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js +72 -0
  94. package/core/server/data/migrations/versions/5.21/2022-10-26-09-32-add-feedback-enabled-column-to-emails.js +7 -0
  95. package/core/server/data/migrations/versions/5.21/2022-10-27-09-50-add-member-track-source-setting.js +8 -0
  96. package/core/server/data/schema/commands.js +107 -48
  97. package/core/server/data/schema/default-settings/default-settings.json +8 -0
  98. package/core/server/data/schema/fixtures/fixture-manager.js +16 -14
  99. package/core/server/data/schema/fixtures/fixtures.json +14 -2
  100. package/core/server/data/schema/schema.js +7 -3
  101. package/core/server/models/base/plugins/actions.js +1 -1
  102. package/core/server/models/base/plugins/crud.js +12 -0
  103. package/core/server/models/email-recipient.js +14 -0
  104. package/core/server/models/email.js +2 -5
  105. package/core/server/models/member-click-event.js +37 -0
  106. package/core/server/models/member-created-event.js +23 -0
  107. package/core/server/models/member-paid-subscription-event.js +19 -0
  108. package/core/server/models/member.js +6 -0
  109. package/core/server/models/post.js +33 -2
  110. package/core/server/models/redirect.js +1 -0
  111. package/core/server/models/subscription-created-event.js +7 -0
  112. package/core/server/services/audience-feedback/index.js +2 -0
  113. package/core/server/services/link-redirection/LinkRedirectRepository.js +16 -5
  114. package/core/server/services/link-tracking/PostLinkRepository.js +26 -2
  115. package/core/server/services/link-tracking/index.js +3 -1
  116. package/core/server/services/mega/feedback-buttons.js +87 -16
  117. package/core/server/services/mega/mega.js +1 -0
  118. package/core/server/services/mega/template.js +3 -0
  119. package/core/server/services/member-attribution/index.js +3 -1
  120. package/core/server/services/members/api.js +4 -1
  121. package/core/server/services/members/service.js +8 -1
  122. package/core/server/services/newsletters/index.js +3 -1
  123. package/core/server/services/newsletters/service.js +11 -1
  124. package/core/server/services/tiers/TierRepository.js +116 -0
  125. package/core/server/services/tiers/index.js +1 -0
  126. package/core/server/services/tiers/service.js +32 -0
  127. package/core/server/services/url/UrlGenerator.js +4 -2
  128. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  129. package/core/shared/config/defaults.json +1 -1
  130. package/core/shared/labs.js +7 -8
  131. package/ghost.js +1 -0
  132. package/package.json +117 -113
  133. package/yarn.lock +1187 -1129
  134. package/components/tryghost-adapter-manager-5.19.3.tgz +0 -0
  135. package/components/tryghost-api-version-compatibility-service-5.19.3.tgz +0 -0
  136. package/components/tryghost-audience-feedback-5.19.3.tgz +0 -0
  137. package/components/tryghost-bootstrap-socket-5.19.3.tgz +0 -0
  138. package/components/tryghost-constants-5.19.3.tgz +0 -0
  139. package/components/tryghost-custom-theme-settings-service-5.19.3.tgz +0 -0
  140. package/components/tryghost-domain-events-5.19.3.tgz +0 -0
  141. package/components/tryghost-email-analytics-provider-mailgun-5.19.3.tgz +0 -0
  142. package/components/tryghost-email-analytics-service-5.19.3.tgz +0 -0
  143. package/components/tryghost-email-content-generator-5.19.3.tgz +0 -0
  144. package/components/tryghost-express-dynamic-redirects-5.19.3.tgz +0 -0
  145. package/components/tryghost-extract-api-key-5.19.3.tgz +0 -0
  146. package/components/tryghost-html-to-plaintext-5.19.3.tgz +0 -0
  147. package/components/tryghost-link-redirects-5.19.3.tgz +0 -0
  148. package/components/tryghost-link-replacer-5.19.3.tgz +0 -0
  149. package/components/tryghost-link-tracking-5.19.3.tgz +0 -0
  150. package/components/tryghost-magic-link-5.19.3.tgz +0 -0
  151. package/components/tryghost-mailgun-client-5.19.3.tgz +0 -0
  152. package/components/tryghost-member-analytics-service-5.19.3.tgz +0 -0
  153. package/components/tryghost-member-attribution-5.19.3.tgz +0 -0
  154. package/components/tryghost-member-events-5.19.3.tgz +0 -0
  155. package/components/tryghost-members-analytics-ingress-5.19.3.tgz +0 -0
  156. package/components/tryghost-members-api-5.19.3.tgz +0 -0
  157. package/components/tryghost-members-csv-5.19.3.tgz +0 -0
  158. package/components/tryghost-members-events-service-5.19.3.tgz +0 -0
  159. package/components/tryghost-members-importer-5.19.3.tgz +0 -0
  160. package/components/tryghost-members-offers-5.19.3.tgz +0 -0
  161. package/components/tryghost-members-payments-5.19.3.tgz +0 -0
  162. package/components/tryghost-members-stripe-service-5.19.3.tgz +0 -0
  163. package/components/tryghost-minifier-5.19.3.tgz +0 -0
  164. package/components/tryghost-mw-api-version-mismatch-5.19.3.tgz +0 -0
  165. package/components/tryghost-mw-cache-control-5.19.3.tgz +0 -0
  166. package/components/tryghost-mw-error-handler-5.19.3.tgz +0 -0
  167. package/components/tryghost-mw-session-from-token-5.19.3.tgz +0 -0
  168. package/components/tryghost-mw-update-user-last-seen-5.19.3.tgz +0 -0
  169. package/components/tryghost-mw-vhost-5.19.3.tgz +0 -0
  170. package/components/tryghost-oembed-service-5.19.3.tgz +0 -0
  171. package/components/tryghost-referrers-5.19.3.tgz +0 -0
  172. package/components/tryghost-security-5.19.3.tgz +0 -0
  173. package/components/tryghost-session-service-5.19.3.tgz +0 -0
  174. package/components/tryghost-settings-path-manager-5.19.3.tgz +0 -0
  175. package/components/tryghost-staff-service-5.19.3.tgz +0 -0
  176. package/components/tryghost-stats-service-5.19.3.tgz +0 -0
  177. package/components/tryghost-verification-trigger-5.19.3.tgz +0 -0
  178. package/components/tryghost-version-notifications-data-service-5.19.3.tgz +0 -0
  179. package/core/built/admin/assets/ghost-982146a4ada3a5af1981d1919ae01d08.css +0 -1
  180. package/core/built/admin/assets/ghost-dark-41929e4857de411a23597a9de49a4e4f.css +0 -1
@@ -8,6 +8,10 @@ const MemberPaidSubscriptionEvent = ghostBookshelf.Model.extend({
8
8
  return this.belongsTo('Member', 'member_id', 'id');
9
9
  },
10
10
 
11
+ stripeSubscription() {
12
+ return this.belongsTo('StripeCustomerSubscription', 'subscription_id', 'id');
13
+ },
14
+
11
15
  subscriptionCreatedEvent() {
12
16
  return this.belongsTo('SubscriptionCreatedEvent', 'subscription_id', 'subscription_id');
13
17
  },
@@ -25,6 +29,21 @@ const MemberPaidSubscriptionEvent = ghostBookshelf.Model.extend({
25
29
  .groupByRaw('currency, DATE(created_at)')
26
30
  .orderByRaw('DATE(created_at)');
27
31
  }
32
+ },
33
+
34
+ filterRelations() {
35
+ return {
36
+ subscriptionCreatedEvent: {
37
+ // Mongo-knex doesn't support belongsTo relations
38
+ tableName: 'members_subscription_created_events',
39
+ tableNameAs: 'subscriptionCreatedEvent',
40
+ type: 'manyToMany',
41
+ joinTable: 'members_paid_subscription_events',
42
+ joinFrom: 'id',
43
+ joinToForeign: 'subscription_id',
44
+ joinTo: 'subscription_id'
45
+ }
46
+ };
28
47
  }
29
48
  }, {
30
49
  permittedOptions(methodName) {
@@ -114,6 +114,12 @@ const Member = ghostBookshelf.Model.extend({
114
114
  joinTable: 'email_recipients',
115
115
  joinFrom: 'member_id',
116
116
  joinTo: 'email_id'
117
+ },
118
+ feedback: {
119
+ tableName: 'members_feedback',
120
+ tableNameAs: 'feedback',
121
+ type: 'oneToOne',
122
+ joinFrom: 'member_id'
117
123
  }
118
124
  };
119
125
  },
@@ -236,6 +236,17 @@ Post = ghostBookshelf.Model.extend({
236
236
  },
237
237
 
238
238
  orderRawQuery: function orderRawQuery(field, direction, withRelated) {
239
+ if (field === 'sentiment') {
240
+ if (withRelated.includes('count.sentiment')) {
241
+ // Internally sentiment can be included via the count.sentiment relation. We can do a quick optimisation of the query in that case.
242
+ return {
243
+ orderByRaw: `count__sentiment ${direction}`
244
+ };
245
+ }
246
+ return {
247
+ orderByRaw: `(select AVG(score) from \`members_feedback\` where posts.id = members_feedback.post_id) ${direction}`
248
+ };
249
+ }
239
250
  if (field === 'email.open_rate' && withRelated && withRelated.indexOf('email') > -1) {
240
251
  return {
241
252
  // *1.0 is needed on one of the columns to prevent sqlite from
@@ -1346,6 +1357,26 @@ Post = ghostBookshelf.Model.extend({
1346
1357
  .as('count__paid_conversions');
1347
1358
  });
1348
1359
  },
1360
+ /**
1361
+ * Combination of sigups and paid conversions, but unique per member
1362
+ */
1363
+ conversions(modelOrCollection) {
1364
+ modelOrCollection.query('columns', 'posts.*', (qb) => {
1365
+ qb.count('*')
1366
+ .from('k')
1367
+ .with('k', (q) => {
1368
+ q.select('member_id')
1369
+ .from('members_subscription_created_events')
1370
+ .whereRaw('posts.id = members_subscription_created_events.attribution_id')
1371
+ .union(function () {
1372
+ this.select('member_id')
1373
+ .from('members_created_events')
1374
+ .whereRaw('posts.id = members_created_events.attribution_id');
1375
+ });
1376
+ })
1377
+ .as('count__conversions');
1378
+ });
1379
+ },
1349
1380
  clicks(modelOrCollection) {
1350
1381
  modelOrCollection.query('columns', 'posts.*', (qb) => {
1351
1382
  qb.countDistinct('members_click_events.member_id')
@@ -1357,7 +1388,7 @@ Post = ghostBookshelf.Model.extend({
1357
1388
  },
1358
1389
  sentiment(modelOrCollection) {
1359
1390
  modelOrCollection.query('columns', 'posts.*', (qb) => {
1360
- qb.select(qb.client.raw('ROUND(AVG(score) * 100)'))
1391
+ qb.select(qb.client.raw('COALESCE(ROUND(AVG(score) * 100), 0)'))
1361
1392
  .from('members_feedback')
1362
1393
  .whereRaw('posts.id = members_feedback.post_id')
1363
1394
  .as('count__sentiment');
@@ -1368,7 +1399,7 @@ Post = ghostBookshelf.Model.extend({
1368
1399
  qb.count('*')
1369
1400
  .from('members_feedback')
1370
1401
  .whereRaw('posts.id = members_feedback.post_id AND members_feedback.score = 0')
1371
- .as('count__positive_feedback');
1402
+ .as('count__negative_feedback');
1372
1403
  });
1373
1404
  },
1374
1405
  positive_feedback(modelOrCollection) {
@@ -53,6 +53,7 @@ const Redirect = ghostBookshelf.Model.extend({
53
53
  qb.countDistinct('members_click_events.member_id')
54
54
  .from('members_click_events')
55
55
  .whereRaw('redirects.id = members_click_events.redirect_id')
56
+ .whereRaw('redirects.updated_at <= members_click_events.created_at')
56
57
  .as('count__clicks');
57
58
  });
58
59
  }
@@ -8,6 +8,13 @@ const SubscriptionCreatedEvent = ghostBookshelf.Model.extend({
8
8
  return this.belongsTo('Member', 'member_id', 'id');
9
9
  },
10
10
 
11
+ /**
12
+ * The member created event that happend at the same time (if any)
13
+ */
14
+ memberCreatedEvent() {
15
+ return this.belongsTo('MemberCreatedEvent', 'batch_id', 'batch_id');
16
+ },
17
+
11
18
  subscription() {
12
19
  return this.belongsTo('StripeCustomerSubscription', 'subscription_id', 'id');
13
20
  },
@@ -1,4 +1,5 @@
1
1
  const urlUtils = require('../../../shared/url-utils');
2
+ const urlService = require('../../services/url');
2
3
  const FeedbackRepository = require('./FeedbackRepository');
3
4
 
4
5
  class AudienceFeedbackServiceWrapper {
@@ -22,6 +23,7 @@ class AudienceFeedbackServiceWrapper {
22
23
 
23
24
  // Expose the service
24
25
  this.service = new AudienceFeedbackService({
26
+ urlService,
25
27
  config: {
26
28
  baseURL: new URL(urlUtils.urlFor('home', true))
27
29
  }
@@ -18,7 +18,7 @@ module.exports = class LinkRedirectRepository {
18
18
  }
19
19
 
20
20
  /**
21
- * @param {InstanceType<LinkRedirect>} linkRedirect
21
+ * @param {InstanceType<LinkRedirect>} linkRedirect
22
22
  * @returns {Promise<void>}
23
23
  */
24
24
  async save(linkRedirect) {
@@ -36,10 +36,14 @@ module.exports = class LinkRedirectRepository {
36
36
  }
37
37
 
38
38
  fromModel(model) {
39
+ // Store if link has been edited
40
+ const edited = model.get('created_at')?.getTime() !== model.get('updated_at')?.getTime();
41
+
39
42
  return new LinkRedirect({
40
43
  id: model.id,
41
44
  from: new URL(this.#trimLeadingSlash(model.get('from')), this.#urlUtils.urlFor('home', true)),
42
- to: new URL(model.get('to'))
45
+ to: new URL(model.get('to')),
46
+ edited
43
47
  });
44
48
  }
45
49
 
@@ -55,10 +59,17 @@ module.exports = class LinkRedirectRepository {
55
59
  return result;
56
60
  }
57
61
 
62
+ async getFilteredIds(options) {
63
+ const linkRows = await this.#LinkRedirect.getFilteredCollectionQuery(options)
64
+ .select('redirects.id')
65
+ .distinct();
66
+ return linkRows.map(row => row.id);
67
+ }
68
+
58
69
  /**
59
- *
60
- * @param {URL} url
61
- * @returns {Promise<InstanceType<LinkRedirect>|undefined>} linkRedirect
70
+ *
71
+ * @param {URL} url
72
+ * @returns {Promise<InstanceType<LinkRedirect>|undefined>} linkRedirect
62
73
  */
63
74
  async getByURL(url) {
64
75
  // Strip subdirectory from path
@@ -1,4 +1,5 @@
1
1
  const {FullPostLink} = require('@tryghost/link-tracking');
2
+ const _ = require('lodash');
2
3
 
3
4
  /**
4
5
  * @typedef {import('bson-objectid').default} ObjectID
@@ -22,8 +23,8 @@ module.exports = class PostLinkRepository {
22
23
  }
23
24
 
24
25
  /**
25
- *
26
- * @param {*} options
26
+ *
27
+ * @param {*} options
27
28
  * @returns {Promise<InstanceType<FullPostLink>[]>}
28
29
  */
29
30
  async getAll(options) {
@@ -48,6 +49,29 @@ module.exports = class PostLinkRepository {
48
49
  return result;
49
50
  }
50
51
 
52
+ async updateLinks(linkIds, updateData, options) {
53
+ const bulkUpdateOptions = _.pick(options, ['transacting']);
54
+
55
+ const bulkActionResult = await this.#LinkRedirect.bulkEdit(linkIds, 'redirects', {
56
+ ...bulkUpdateOptions,
57
+ data: updateData
58
+ });
59
+
60
+ return {
61
+ bulk: {
62
+ action: 'updateLink',
63
+ meta: {
64
+ stats: {
65
+ successful: bulkActionResult.successful,
66
+ unsuccessful: bulkActionResult.unsuccessful
67
+ },
68
+ errors: bulkActionResult.errors,
69
+ unsuccessfulData: bulkActionResult.unsuccessfulData
70
+ }
71
+ }
72
+ };
73
+ }
74
+
51
75
  /**
52
76
  * @param {PostLink} postLink
53
77
  * @returns {Promise<void>}
@@ -1,6 +1,7 @@
1
1
  const LinkClickRepository = require('./LinkClickRepository');
2
2
  const PostLinkRepository = require('./PostLinkRepository');
3
3
  const errors = require('@tryghost/errors');
4
+ const urlUtils = require('../../../shared/url-utils');
4
5
 
5
6
  class LinkTrackingServiceWrapper {
6
7
  async init() {
@@ -38,7 +39,8 @@ class LinkTrackingServiceWrapper {
38
39
  linkRedirectService: linkRedirection.service,
39
40
  linkClickRepository: this.linkClickRepository,
40
41
  postLinkRepository,
41
- DomainEvents
42
+ DomainEvents,
43
+ urlUtils
42
44
  });
43
45
 
44
46
  await this.service.init();
@@ -18,20 +18,32 @@ const generateLinks = (postId, uuid, html) => {
18
18
  0
19
19
  );
20
20
 
21
- html = html.replace(templateStrings.like, positiveLink.href);
22
- html = html.replace(templateStrings.dislike, negativeLink.href);
21
+ html = html.replace(new RegExp(templateStrings.like, 'g'), positiveLink.href);
22
+ html = html.replace(new RegExp(templateStrings.dislike, 'g'), negativeLink.href);
23
23
 
24
24
  return html;
25
25
  };
26
26
 
27
27
  const getTemplate = (accentColor) => {
28
- const likeButtonHtml = getButtonHtml(templateStrings.like, 'More like this', accentColor);
29
- const dislikeButtonHtml = getButtonHtml(templateStrings.dislike, 'Less like this', accentColor);
28
+ const likeButtonHtml = getButtonHtml(
29
+ templateStrings.like,
30
+ 'More like this',
31
+ accentColor,
32
+ 'like-icon',
33
+ 'https://static.ghost.org/v5.0.0/images/thumbs-up.png'
34
+ );
35
+ const dislikeButtonHtml = getButtonHtml(
36
+ templateStrings.dislike,
37
+ 'Less like this',
38
+ accentColor,
39
+ 'dislike-icon',
40
+ 'https://static.ghost.org/v5.0.0/images/thumbs-down.png'
41
+ );
30
42
 
31
43
  return (`
32
44
  <tr>
33
45
  <td dir="ltr" width="100%" style="background-color: #ffffff; text-align: center; padding: 40px 4px; border-bottom: 1px solid #e5eff5" align="center">
34
- <h3 style="text-align: center; margin-bottom: 22px; font-size: 17px; letter-spacing: -0.2px; margin-top: 0 !important;">What did you think of this post?</h3>
46
+ <h3 style="text-align: center; margin-bottom: 22px; font-size: 17px; letter-spacing: -0.2px; margin-top: 0 !important;">Give feedback on this post</h3>
35
47
  <table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: auto; width: auto !important;">
36
48
  <tr>
37
49
  ${likeButtonHtml}
@@ -43,19 +55,44 @@ const getTemplate = (accentColor) => {
43
55
  `);
44
56
  };
45
57
 
46
- function getButtonHtml(href, buttonText, accentColor) {
47
- const color = new Color(accentColor);
48
- const bgColor = `${accentColor}10`;
49
- const textColor = color.darken(0.6).hex();
58
+ function getButtonHtml(href, buttonText, accentColor, className, iconUrl) {
59
+ const bgColor = getButtonLightTheme(accentColor).backgroundColor;
60
+ const textColor = getButtonLightTheme(accentColor).color;
50
61
 
51
62
  return (`
52
- <td dir="ltr" valign="top" align="center" style="font-family: inherit; font-size: 14px; text-align: center;" nowrap>
53
- <table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="width: auto !important;">
63
+ <td dir="ltr" valign="top" align="center" style="vertical-align: top; color: ${textColor}; font-family: inherit; font-size: 14px; text-align: center; padding: 0 8px;" nowrap>
64
+ <table class="feedback-buttons" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="background-color: ${bgColor}; overflow: hidden; border-radius: 22px;border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
54
65
  <tr>
55
- <td style="padding: 0 6px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';">
56
- <a href=${href} style="background-color: ${bgColor}; color: ${textColor}; border-radius: 22px; font-family: inherit; padding: 12px 20px; border: none; font-size: 14px; font-weight: bold; line-height: 100%; text-decoration: none; display: block;">
57
- ${buttonText}
58
- </a>
66
+ <td width="16" height="38" style="paddig-left:10px;"></td>
67
+ <td class=${className} background=${iconUrl} bgcolor="${textColor}" width="24" height="38" valign="top" style="background-image: url(${iconUrl});vertical-align: middle; text-align: center;background-size: cover; background-position: 0 50%; background-repeat:no-repeat;">
68
+ <!--[if gte mso 9]>
69
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width:24px;height:38px;">
70
+ <v:fill origin="0.5, 0.5" position="0.5, 0.5" type="tile" src=${iconUrl} color="${textColor}" size="1,1" aspect="atleast" />
71
+ <v:textbox inset="0,0,0,0">
72
+ <![endif]-->
73
+ <div>
74
+ <a style="background-color: ${bgColor};border: none; width: 24px; height: 38px; display: block" href=${href} target="_blank"></a>
75
+ </div>
76
+ <!--[if gte mso 9]>
77
+ </v:textbox>
78
+ </v:rect>
79
+ <![endif]-->
80
+ </td>
81
+ <td style="text-align: right;font-size: 18px; vertical-align: middle; color: ${textColor}!important; background-position: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';">
82
+ <div style="color: ${textColor}"><!--[if mso]>
83
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href=${href} style="height:38px;v-text-anchor:middle;width:120px;" stroke="f">
84
+ <w:anchorlock/>
85
+ <center>
86
+ <![endif]-->
87
+ <a
88
+ href=${href}
89
+ target="_blank"
90
+ style="padding: 0 8px 0 8px;border-radius: 0 22px 22px 0;color:${textColor}!important;display:inline-block;font-family: inherit;font-size:14px;font-weight:bold;line-height:38px;text-align:left;text-decoration:none;width:100px;-webkit-text-size-adjust:none;">
91
+ ${buttonText}</a>
92
+ <!--[if mso]>
93
+ </center>
94
+ </v:rect>
95
+ <![endif]--></div>
59
96
  </td>
60
97
  </tr>
61
98
  </table>
@@ -63,7 +100,41 @@ function getButtonHtml(href, buttonText, accentColor) {
63
100
  `);
64
101
  }
65
102
 
103
+ function getButtonLightTheme(accentColor) {
104
+ const color = new Color(accentColor);
105
+ const backgroundColor = `${accentColor}10`;
106
+ const textColor = color.darken(0.6).hex();
107
+
108
+ return {
109
+ color: textColor,
110
+ backgroundColor
111
+ };
112
+ }
113
+
114
+ function getButtonsHeadStyles() {
115
+ return (`
116
+ .like-icon {
117
+ mix-blend-mode: darken;
118
+ }
119
+
120
+ .dislike-icon {
121
+ mix-blend-mode: darken;
122
+ }
123
+
124
+ @media (prefers-color-scheme: dark) {
125
+ .like-icon {
126
+ mix-blend-mode: initial !important;
127
+ }
128
+
129
+ .dislike-icon {
130
+ mix-blend-mode: initial !important;
131
+ }
132
+ }
133
+ `);
134
+ }
135
+
66
136
  module.exports = {
67
137
  generateLinks,
68
- getTemplate
138
+ getTemplate,
139
+ getButtonsHeadStyles
69
140
  };
@@ -240,6 +240,7 @@ const addEmail = async (postModel, options) => {
240
240
  submitted_at: moment().toDate(),
241
241
  track_opens: !!settingsCache.get('email_track_opens'),
242
242
  track_clicks: !!settingsCache.get('email_track_clicks'),
243
+ feedback_enabled: !!newsletter.get('feedback_enabled'),
243
244
  recipient_filter: emailRecipientFilter,
244
245
  newsletter_id: newsletter.id
245
246
  }, knexOptions);
@@ -37,6 +37,7 @@ module.exports = ({post, site, newsletter, templateSettings}) => {
37
37
  <head>
38
38
  <meta name="viewport" content="width=device-width" />
39
39
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
40
+ <!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]-->
40
41
  <title>${cleanPost.title}</title>
41
42
  <style>
42
43
  /* -------------------------------------
@@ -1161,6 +1162,8 @@ ${ templateSettings.showBadge ? `
1161
1162
  }
1162
1163
  ` : ''}
1163
1164
 
1165
+ ${iff(templateSettings.feedbackEnabled, feedbackButtons.getButtonsHeadStyles(templateSettings.accentColor), '')}
1166
+
1164
1167
  </style>
1165
1168
  </head>
1166
1169
 
@@ -1,5 +1,6 @@
1
1
  const urlService = require('../url');
2
2
  const urlUtils = require('../../../shared/url-utils');
3
+ const settingsCache = require('../../../shared/settings-cache');
3
4
 
4
5
  class MemberAttributionServiceWrapper {
5
6
  init() {
@@ -38,7 +39,8 @@ class MemberAttributionServiceWrapper {
38
39
  SubscriptionCreatedEvent: models.SubscriptionCreatedEvent,
39
40
  Integration: models.Integration
40
41
  },
41
- attributionBuilder: this.attributionBuilder
42
+ attributionBuilder: this.attributionBuilder,
43
+ isTrackingEnabled: !!settingsCache.get('members_track_sources')
42
44
  });
43
45
  }
44
46
  }
@@ -13,6 +13,7 @@ const SingleUseTokenProvider = require('./SingleUseTokenProvider');
13
13
  const urlUtils = require('../../../shared/url-utils');
14
14
  const labsService = require('../../../shared/labs');
15
15
  const offersService = require('../offers');
16
+ const tiersService = require('../tiers');
16
17
  const newslettersService = require('../newsletters');
17
18
  const memberAttributionService = require('../member-attribution');
18
19
 
@@ -194,9 +195,11 @@ function createApiInstance(config) {
194
195
  StripePrice: models.StripePrice,
195
196
  Product: models.Product,
196
197
  Settings: models.Settings,
197
- Comment: models.Comment
198
+ Comment: models.Comment,
199
+ MemberFeedback: models.MemberFeedback
198
200
  },
199
201
  stripeAPIService: stripeService.api,
202
+ tiersService: tiersService,
200
203
  offersAPI: offersService.api,
201
204
  labsService: labsService,
202
205
  newslettersService: newslettersService,
@@ -16,6 +16,7 @@ const config = require('../../../shared/config');
16
16
  const models = require('../../models');
17
17
  const {GhostMailer} = require('../mail');
18
18
  const jobsService = require('../jobs');
19
+ const tiersService = require('../tiers');
19
20
  const VerificationTrigger = require('@tryghost/verification-trigger');
20
21
  const DatabaseInfo = require('@tryghost/database-info');
21
22
  const settingsHelpers = require('../settings-helpers');
@@ -47,7 +48,13 @@ let verificationTrigger;
47
48
  const membersImporter = new MembersCSVImporter({
48
49
  storagePath: config.getContentPath('data'),
49
50
  getTimezone: () => settingsCache.get('timezone'),
50
- getMembersApi: () => module.exports.api,
51
+ getMembersRepository: async () => {
52
+ const api = await module.exports.api;
53
+ return api.members;
54
+ },
55
+ getDefaultTier: () => {
56
+ return tiersService.api.readDefaultTier();
57
+ },
51
58
  sendEmail: ghostMailer.send.bind(ghostMailer),
52
59
  isSet: labsService.isSet.bind(labsService),
53
60
  addJob: jobsService.addJob.bind(jobsService),
@@ -4,6 +4,7 @@ const mail = require('../mail');
4
4
  const models = require('../../models');
5
5
  const urlUtils = require('../../../shared/url-utils');
6
6
  const limitService = require('../limits');
7
+ const labs = require('../../../shared/labs');
7
8
 
8
9
  const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
9
10
 
@@ -13,5 +14,6 @@ module.exports = new NewslettersService({
13
14
  mail,
14
15
  singleUseTokenProvider: new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY),
15
16
  urlUtils,
16
- limitService
17
+ limitService,
18
+ labs
17
19
  });
@@ -21,13 +21,16 @@ class NewslettersService {
21
21
  * @param {Object} options.singleUseTokenProvider
22
22
  * @param {Object} options.urlUtils
23
23
  * @param {ILimitService} options.limitService
24
+ * @param {Object} options.labs
24
25
  */
25
- constructor({NewsletterModel, MemberModel, mail, singleUseTokenProvider, urlUtils, limitService}) {
26
+ constructor({NewsletterModel, MemberModel, mail, singleUseTokenProvider, urlUtils, limitService, labs}) {
26
27
  this.NewsletterModel = NewsletterModel;
27
28
  this.MemberModel = MemberModel;
28
29
  this.urlUtils = urlUtils;
29
30
  /** @private */
30
31
  this.limitService = limitService;
32
+ /** @private */
33
+ this.labs = labs;
31
34
 
32
35
  /* email verification setup */
33
36
 
@@ -251,6 +254,13 @@ class NewslettersService {
251
254
  }
252
255
  }
253
256
 
257
+ if (cleanedAttrs.feedback_enabled) {
258
+ if (!this.labs.isSet('audienceFeedback')) {
259
+ // Not allowed to set to true
260
+ cleanedAttrs.feedback_enabled = false;
261
+ }
262
+ }
263
+
254
264
  return {cleanedAttrs, emailsToVerify};
255
265
  }
256
266
 
@@ -0,0 +1,116 @@
1
+ const {Tier} = require('@tryghost/tiers');
2
+
3
+ /**
4
+ * @typedef {import('@tryghost/tiers/lib/TiersAPI').ITierRepository} ITierRepository
5
+ */
6
+
7
+ /**
8
+ * @implements {ITierRepository}
9
+ */
10
+ module.exports = class TierRepository {
11
+ /** @type {Object} */
12
+ #ProductModel;
13
+
14
+ /** @type {import('@tryghost/domain-events')} */
15
+ #DomainEvents;
16
+
17
+ /**
18
+ * @param {object} deps
19
+ * @param {object} deps.ProductModel Bookshelf Model
20
+ * @param {import('@tryghost/domain-events')} deps.DomainEvents
21
+ */
22
+ constructor(deps) {
23
+ this.#ProductModel = deps.ProductModel;
24
+ this.#DomainEvents = deps.DomainEvents;
25
+ }
26
+
27
+ /**
28
+ * @private
29
+ */
30
+ mapToTier(model) {
31
+ const json = model.toJSON();
32
+ return {
33
+ id: json.id,
34
+ name: json.name,
35
+ slug: json.slug,
36
+ status: json.active ? 'active' : 'archived',
37
+ welcomePageURL: json.welcome_page_url,
38
+ visibility: json.visibility,
39
+ trialDays: json.trial_days,
40
+ description: json.description,
41
+ type: json.type,
42
+ currency: json.currency,
43
+ monthlyPrice: json.monthly_price,
44
+ yearlyPrice: json.yearly_price,
45
+ createdAt: json.created_at,
46
+ updatedAt: json.updated_at,
47
+ benefits: json.benefits.map(item => item.name)
48
+ };
49
+ }
50
+
51
+ /**
52
+ * @param {object} [options]
53
+ * @param {string} [options.filter]
54
+ * @returns {Promise<import('@tryghost/tiers/lib/Tier')[]>}
55
+ */
56
+ async getAll(options = {}) {
57
+ const collection = await this.#ProductModel.findAll({...options, withRelated: ['benefits']});
58
+
59
+ const result = [];
60
+
61
+ for (const model of collection.models) {
62
+ const tier = await Tier.create(this.mapToTier(model));
63
+ result.push(tier);
64
+ }
65
+
66
+ return result;
67
+ }
68
+
69
+ /**
70
+ * @param {import('bson-objectid').default} id
71
+ * @returns {Promise<import('@tryghost/tiers/lib/Tier')>}
72
+ */
73
+ async getById(id) {
74
+ const model = await this.#ProductModel.findOne({id: id.toHexString()}, {withRelated: ['benefits']});
75
+
76
+ return await Tier.create(this.mapToTier(model));
77
+ }
78
+
79
+ /**
80
+ * @param {import('@tryghost/tiers/lib/Tier')} tier
81
+ * @returns {Promise<void>}
82
+ */
83
+ async save(tier) {
84
+ const data = {
85
+ id: tier.id.toHexString(),
86
+ name: tier.name,
87
+ slug: tier.slug,
88
+ active: tier.status === 'active',
89
+ welcome_page_url: tier.welcomePageURL,
90
+ visibility: tier.visibility,
91
+ trial_days: tier.trialDays,
92
+ description: tier.description,
93
+ type: tier.type,
94
+ currency: tier.currency,
95
+ monthly_price: tier.monthlyPrice,
96
+ yearly_price: tier.yearlyPrice,
97
+ created_at: tier.createdAt,
98
+ updated_at: tier.updatedAt,
99
+ benefits: tier.benefits.map(name => ({name}))
100
+ };
101
+
102
+ const existing = await this.#ProductModel.findOne({id: data.id}, {require: false});
103
+
104
+ if (!existing) {
105
+ await this.#ProductModel.add(data);
106
+ } else {
107
+ await this.#ProductModel.edit(data, {
108
+ id: data.id
109
+ });
110
+ }
111
+
112
+ for (const event of tier.events) {
113
+ this.#DomainEvents.dispatch(event);
114
+ }
115
+ }
116
+ };
@@ -0,0 +1 @@
1
+ module.exports = require('./service');