ghost 5.15.0 → 5.16.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 (120) hide show
  1. package/components/tryghost-adapter-manager-5.16.0.tgz +0 -0
  2. package/components/{tryghost-api-framework-5.15.0.tgz → tryghost-api-framework-5.16.0.tgz} +0 -0
  3. package/components/{tryghost-api-version-compatibility-service-5.15.0.tgz → tryghost-api-version-compatibility-service-5.16.0.tgz} +0 -0
  4. package/components/tryghost-bootstrap-socket-5.16.0.tgz +0 -0
  5. package/components/{tryghost-constants-5.15.0.tgz → tryghost-constants-5.16.0.tgz} +0 -0
  6. package/components/tryghost-custom-theme-settings-service-5.16.0.tgz +0 -0
  7. package/components/{tryghost-domain-events-5.15.0.tgz → tryghost-domain-events-5.16.0.tgz} +0 -0
  8. package/components/{tryghost-email-analytics-provider-mailgun-5.15.0.tgz → tryghost-email-analytics-provider-mailgun-5.16.0.tgz} +0 -0
  9. package/components/{tryghost-email-analytics-service-5.15.0.tgz → tryghost-email-analytics-service-5.16.0.tgz} +0 -0
  10. package/components/{tryghost-email-content-generator-5.15.0.tgz → tryghost-email-content-generator-5.16.0.tgz} +0 -0
  11. package/components/{tryghost-express-dynamic-redirects-5.15.0.tgz → tryghost-express-dynamic-redirects-5.16.0.tgz} +0 -0
  12. package/components/tryghost-extract-api-key-5.16.0.tgz +0 -0
  13. package/components/{tryghost-html-to-plaintext-5.15.0.tgz → tryghost-html-to-plaintext-5.16.0.tgz} +0 -0
  14. package/components/{tryghost-job-manager-5.15.0.tgz → tryghost-job-manager-5.16.0.tgz} +0 -0
  15. package/components/tryghost-link-redirects-5.16.0.tgz +0 -0
  16. package/components/tryghost-link-replacer-5.16.0.tgz +0 -0
  17. package/components/tryghost-link-tracking-5.16.0.tgz +0 -0
  18. package/components/tryghost-magic-link-5.16.0.tgz +0 -0
  19. package/components/tryghost-mailgun-client-5.16.0.tgz +0 -0
  20. package/components/{tryghost-member-analytics-service-5.15.0.tgz → tryghost-member-analytics-service-5.16.0.tgz} +0 -0
  21. package/components/tryghost-member-attribution-5.16.0.tgz +0 -0
  22. package/components/tryghost-member-events-5.16.0.tgz +0 -0
  23. package/components/{tryghost-members-analytics-ingress-5.15.0.tgz → tryghost-members-analytics-ingress-5.16.0.tgz} +0 -0
  24. package/components/tryghost-members-api-5.16.0.tgz +0 -0
  25. package/components/{tryghost-members-csv-5.15.0.tgz → tryghost-members-csv-5.16.0.tgz} +0 -0
  26. package/components/tryghost-members-events-service-5.16.0.tgz +0 -0
  27. package/components/{tryghost-members-importer-5.15.0.tgz → tryghost-members-importer-5.16.0.tgz} +0 -0
  28. package/components/{tryghost-members-offers-5.15.0.tgz → tryghost-members-offers-5.16.0.tgz} +0 -0
  29. package/components/{tryghost-members-payments-5.15.0.tgz → tryghost-members-payments-5.16.0.tgz} +0 -0
  30. package/components/{tryghost-members-ssr-5.15.0.tgz → tryghost-members-ssr-5.16.0.tgz} +0 -0
  31. package/components/tryghost-members-stripe-service-5.16.0.tgz +0 -0
  32. package/components/tryghost-minifier-5.16.0.tgz +0 -0
  33. package/components/{tryghost-mw-api-version-mismatch-5.15.0.tgz → tryghost-mw-api-version-mismatch-5.16.0.tgz} +0 -0
  34. package/components/{tryghost-mw-cache-control-5.15.0.tgz → tryghost-mw-cache-control-5.16.0.tgz} +0 -0
  35. package/components/{tryghost-mw-error-handler-5.15.0.tgz → tryghost-mw-error-handler-5.16.0.tgz} +0 -0
  36. package/components/tryghost-mw-session-from-token-5.16.0.tgz +0 -0
  37. package/components/tryghost-mw-update-user-last-seen-5.16.0.tgz +0 -0
  38. package/components/tryghost-mw-vhost-5.16.0.tgz +0 -0
  39. package/components/{tryghost-oembed-service-5.15.0.tgz → tryghost-oembed-service-5.16.0.tgz} +0 -0
  40. package/components/{tryghost-package-json-5.15.0.tgz → tryghost-package-json-5.16.0.tgz} +0 -0
  41. package/components/tryghost-referrers-5.16.0.tgz +0 -0
  42. package/components/tryghost-security-5.16.0.tgz +0 -0
  43. package/components/tryghost-session-service-5.16.0.tgz +0 -0
  44. package/components/tryghost-settings-path-manager-5.16.0.tgz +0 -0
  45. package/components/{tryghost-staff-service-5.15.0.tgz → tryghost-staff-service-5.16.0.tgz} +0 -0
  46. package/components/tryghost-stats-service-5.16.0.tgz +0 -0
  47. package/components/tryghost-update-check-service-5.16.0.tgz +0 -0
  48. package/components/{tryghost-verification-trigger-5.15.0.tgz → tryghost-verification-trigger-5.16.0.tgz} +0 -0
  49. package/components/{tryghost-version-notifications-data-service-5.15.0.tgz → tryghost-version-notifications-data-service-5.16.0.tgz} +0 -0
  50. package/core/boot.js +2 -4
  51. package/core/built/admin/assets/{chunk.143.558b9943af7b15f189ae.js → chunk.143.a281d460e6059cd0210a.js} +6 -6
  52. package/core/built/admin/assets/{chunk.178.a9f6ddaea01e2bc76235.js → chunk.178.68eca2346b6f343991e7.js} +4 -4
  53. package/core/built/admin/assets/{chunk.579.dc11bf8dda5cf4406708.js → chunk.579.d14c3688558f34afeb3e.js} +8462 -7944
  54. package/core/built/admin/assets/{chunk.579.dc11bf8dda5cf4406708.js.LICENSE.txt → chunk.579.d14c3688558f34afeb3e.js.LICENSE.txt} +45 -0
  55. package/core/built/admin/assets/ghost-6491d134c450ca676911ea17e16cd7d4.css +1 -0
  56. package/core/built/admin/assets/ghost-dark-297ab2fcf4cadd1c950b84089a38c5e2.css +1 -0
  57. package/core/built/admin/assets/{ghost-4b1b550e34300f5f4774a261aac29557.js → ghost-f2bf99b26aee662cf37fe59f87b1ceb5.js} +259 -194
  58. package/core/built/admin/assets/img/marketing/analytics-1-aa2d72c4e7347a3cb5666d07916b92aa.jpg +0 -0
  59. package/core/built/admin/assets/img/marketing/analytics-2-389d53f80041ff98111cce79facf66b8.jpg +0 -0
  60. package/core/built/admin/assets/{vendor-271c32988ab16ba175a9bfa2acb2887a.js → vendor-b2375e2f383cbc3fd73340c4b656c993.js} +48 -42
  61. package/core/built/admin/index.html +6 -6
  62. package/core/frontend/helpers/search.js +1 -15
  63. package/core/frontend/src/member-attribution/member-attribution.js +38 -4
  64. package/core/server/api/endpoints/index.js +4 -0
  65. package/core/server/api/endpoints/links.js +25 -0
  66. package/core/server/api/endpoints/posts.js +2 -1
  67. package/core/server/api/endpoints/stats.js +24 -0
  68. package/core/server/api/endpoints/utils/serializers/input/posts.js +6 -1
  69. package/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js +51 -0
  70. package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +10 -1
  71. package/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +1 -1
  72. package/core/server/data/exporter/table-lists.js +3 -1
  73. package/core/server/data/migrations/versions/5.16/2022-09-19-09-04-add-link-redirects-table.js +10 -0
  74. package/core/server/data/migrations/versions/5.16/2022-09-19-09-05-add-members-link-click-events-table.js +8 -0
  75. package/core/server/data/migrations/versions/5.16/2022-09-19-17-44-add-referrer-columns-to-member-events-table.js +21 -0
  76. package/core/server/data/migrations/versions/5.16/2022-09-19-17-44-add-referrer-columns-to-subscription-events-table.js +21 -0
  77. package/core/server/data/schema/schema.js +21 -1
  78. package/core/server/models/link-redirect.js +65 -0
  79. package/core/server/models/member-link-click-event.js +26 -0
  80. package/core/server/models/post.js +21 -5
  81. package/core/server/services/bulk-email/bulk-email-processor.js +7 -5
  82. package/core/server/services/link-redirection/LinkRedirectRepository.js +88 -0
  83. package/core/server/services/link-redirection/index.js +9 -11
  84. package/core/server/services/link-tracking/LinkClickRepository.js +69 -0
  85. package/core/server/services/link-tracking/PostLinkRepository.js +62 -0
  86. package/core/server/services/link-tracking/index.js +48 -0
  87. package/core/server/services/mega/post-email-serializer.js +28 -2
  88. package/core/server/services/member-attribution/index.js +12 -5
  89. package/core/server/services/members/api.js +1 -0
  90. package/core/server/web/admin/app.js +8 -2
  91. package/core/server/web/api/endpoints/admin/routes.js +5 -0
  92. package/core/shared/config/defaults.json +5 -5
  93. package/package.json +109 -108
  94. package/yarn.lock +535 -318
  95. package/components/tryghost-adapter-manager-5.15.0.tgz +0 -0
  96. package/components/tryghost-bootstrap-socket-5.15.0.tgz +0 -0
  97. package/components/tryghost-custom-theme-settings-service-5.15.0.tgz +0 -0
  98. package/components/tryghost-extract-api-key-5.15.0.tgz +0 -0
  99. package/components/tryghost-link-redirects-5.15.0.tgz +0 -0
  100. package/components/tryghost-link-replacement-5.15.0.tgz +0 -0
  101. package/components/tryghost-link-tracking-5.15.0.tgz +0 -0
  102. package/components/tryghost-magic-link-5.15.0.tgz +0 -0
  103. package/components/tryghost-mailgun-client-5.15.0.tgz +0 -0
  104. package/components/tryghost-member-attribution-5.15.0.tgz +0 -0
  105. package/components/tryghost-member-events-5.15.0.tgz +0 -0
  106. package/components/tryghost-members-api-5.15.0.tgz +0 -0
  107. package/components/tryghost-members-events-service-5.15.0.tgz +0 -0
  108. package/components/tryghost-members-stripe-service-5.15.0.tgz +0 -0
  109. package/components/tryghost-minifier-5.15.0.tgz +0 -0
  110. package/components/tryghost-mw-session-from-token-5.15.0.tgz +0 -0
  111. package/components/tryghost-mw-update-user-last-seen-5.15.0.tgz +0 -0
  112. package/components/tryghost-mw-vhost-5.15.0.tgz +0 -0
  113. package/components/tryghost-security-5.15.0.tgz +0 -0
  114. package/components/tryghost-session-service-5.15.0.tgz +0 -0
  115. package/components/tryghost-settings-path-manager-5.15.0.tgz +0 -0
  116. package/components/tryghost-update-check-service-5.15.0.tgz +0 -0
  117. package/core/built/admin/assets/ghost-c933adafb359b75ea1577365ce252e76.css +0 -1
  118. package/core/built/admin/assets/ghost-dark-04981c84bf590e0fae0a8e83e018190f.css +0 -1
  119. package/core/server/services/link-click-tracking/index.js +0 -25
  120. package/core/server/services/link-replacement/index.js +0 -24
@@ -6,6 +6,7 @@ const localUtils = require('../../index');
6
6
  const mobiledoc = require('../../../../../lib/mobiledoc');
7
7
  const postsMetaSchema = require('../../../../../data/schema').tables.posts_meta;
8
8
  const clean = require('./utils/clean');
9
+ const labs = require('../../../../../../shared/labs');
9
10
 
10
11
  function removeSourceFormats(frame) {
11
12
  if (frame.options.formats?.includes('mobiledoc') || frame.options.formats?.includes('lexical')) {
@@ -24,7 +25,11 @@ function defaultRelations(frame) {
24
25
  return false;
25
26
  }
26
27
 
27
- frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.signups', 'count.conversions'];
28
+ if (labs.isSet('emailClicks')) {
29
+ frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.signups', 'count.conversions', 'count.clicks'];
30
+ } else {
31
+ frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email', 'tiers', 'newsletter', 'count.signups', 'count.conversions'];
32
+ }
28
33
  }
29
34
 
30
35
  function setDefaultOrder(frame) {
@@ -1,4 +1,6 @@
1
1
  const mapComment = require('./comments');
2
+ const url = require('../utils/url');
3
+ const _ = require('lodash');
2
4
 
3
5
  const commentEventMapper = (json, frame) => {
4
6
  return {
@@ -7,10 +9,59 @@ const commentEventMapper = (json, frame) => {
7
9
  };
8
10
  };
9
11
 
12
+ const clickEventMapper = (json, frame) => {
13
+ const memberFields = [
14
+ 'id',
15
+ 'uuid',
16
+ 'name',
17
+ 'email',
18
+ 'avatar_image'
19
+ ];
20
+
21
+ const linkFields = [
22
+ 'from',
23
+ 'to'
24
+ ];
25
+
26
+ const postFields = [
27
+ 'id',
28
+ 'uuid',
29
+ 'title',
30
+ 'url'
31
+ ];
32
+
33
+ const data = json.data;
34
+ const response = {};
35
+
36
+ if (data.link && data.link.post) {
37
+ // We could use the post mapper here, but we need less field + don't need al the async behavior support
38
+ url.forPost(data.link.post.id, data.link.post, frame);
39
+ response.post = _.pick(data.link.post, postFields);
40
+ }
41
+
42
+ if (data.link) {
43
+ response.link = _.pick(data.link, linkFields);
44
+ }
45
+
46
+ if (data.member) {
47
+ response.member = _.pick(data.member, memberFields);
48
+ } else {
49
+ response.member = null;
50
+ }
51
+
52
+ return {
53
+ ...json,
54
+ data: response
55
+ };
56
+ };
57
+
10
58
  const activityFeedMapper = (event, frame) => {
11
59
  if (event.type === 'comment_event') {
12
60
  return commentEventMapper(event, frame);
13
61
  }
62
+ if (event.type === 'click_event') {
63
+ return clickEventMapper(event, frame);
64
+ }
14
65
  return event;
15
66
  };
16
67
 
@@ -18,6 +18,15 @@ const memberFields = [
18
18
  'avatar_image'
19
19
  ];
20
20
 
21
+ const memberFieldsAdmin = [
22
+ 'id',
23
+ 'uuid',
24
+ 'name',
25
+ 'email',
26
+ 'expertise',
27
+ 'avatar_image'
28
+ ];
29
+
21
30
  const postFields = [
22
31
  'id',
23
32
  'uuid',
@@ -36,7 +45,7 @@ const commentMapper = (model, frame) => {
36
45
  const response = _.pick(jsonModel, commentFields);
37
46
 
38
47
  if (jsonModel.member) {
39
- response.member = _.pick(jsonModel.member, memberFields);
48
+ response.member = _.pick(jsonModel.member, utils.isMembersAPI(frame) ? memberFields : memberFieldsAdmin);
40
49
  } else {
41
50
  response.member = null;
42
51
  }
@@ -110,7 +110,7 @@ module.exports = async (model, frame, options = {}) => {
110
110
  });
111
111
  }
112
112
 
113
- if (!labs.isSet('memberAttribution')) {
113
+ if (!labs.isSet('memberAttribution') && !labs.isSet('emailClicks')) {
114
114
  delete jsonModel.count;
115
115
  }
116
116
 
@@ -38,7 +38,9 @@ const BACKUP_TABLES = [
38
38
  'comments',
39
39
  'comment_likes',
40
40
  'comment_reports',
41
- 'jobs'
41
+ 'jobs',
42
+ 'link_redirects',
43
+ 'members_link_click_events'
42
44
  ];
43
45
 
44
46
  // NOTE: exposing only tables which are going to be included in a "default" export file
@@ -0,0 +1,10 @@
1
+ const {addTable} = require('../../utils');
2
+
3
+ module.exports = addTable('link_redirects', {
4
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
5
+ from: {type: 'string', maxlength: 2000, nullable: false},
6
+ to: {type: 'string', maxlength: 2000, nullable: false},
7
+ post_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'posts.id', setNullDelete: true},
8
+ created_at: {type: 'dateTime', nullable: false},
9
+ updated_at: {type: 'dateTime', nullable: true}
10
+ });
@@ -0,0 +1,8 @@
1
+ const {addTable} = require('../../utils');
2
+
3
+ module.exports = addTable('members_link_click_events', {
4
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
5
+ member_id: {type: 'string', maxlength: 24, nullable: false, references: 'members.id', cascadeDelete: true},
6
+ link_id: {type: 'string', maxlength: 24, nullable: false, references: 'link_redirects.id', cascadeDelete: true},
7
+ created_at: {type: 'dateTime', nullable: false}
8
+ });
@@ -0,0 +1,21 @@
1
+ const {createAddColumnMigration, combineNonTransactionalMigrations} = require('../../utils');
2
+
3
+ module.exports = combineNonTransactionalMigrations(
4
+ createAddColumnMigration('members_created_events', 'referrer_source', {
5
+ type: 'string',
6
+ maxlength: 191,
7
+ nullable: true
8
+ }),
9
+
10
+ createAddColumnMigration('members_created_events', 'referrer_medium', {
11
+ type: 'string',
12
+ maxlength: 191,
13
+ nullable: true
14
+ }),
15
+
16
+ createAddColumnMigration('members_created_events', 'referrer_url', {
17
+ type: 'string',
18
+ maxlength: 2000,
19
+ nullable: true
20
+ })
21
+ );
@@ -0,0 +1,21 @@
1
+ const {createAddColumnMigration, combineNonTransactionalMigrations} = require('../../utils');
2
+
3
+ module.exports = combineNonTransactionalMigrations(
4
+ createAddColumnMigration('members_subscription_created_events', 'referrer_source', {
5
+ type: 'string',
6
+ maxlength: 191,
7
+ nullable: true
8
+ }),
9
+
10
+ createAddColumnMigration('members_subscription_created_events', 'referrer_medium', {
11
+ type: 'string',
12
+ maxlength: 191,
13
+ nullable: true
14
+ }),
15
+
16
+ createAddColumnMigration('members_subscription_created_events', 'referrer_url', {
17
+ type: 'string',
18
+ maxlength: 2000,
19
+ nullable: true
20
+ })
21
+ );
@@ -497,6 +497,9 @@ module.exports = {
497
497
  }
498
498
  },
499
499
  attribution_url: {type: 'string', maxlength: 2000, nullable: true},
500
+ referrer_source: {type: 'string', maxlength: 191, nullable: true},
501
+ referrer_medium: {type: 'string', maxlength: 191, nullable: true},
502
+ referrer_url: {type: 'string', maxlength: 2000, nullable: true},
500
503
  source: {
501
504
  type: 'string', maxlength: 50, nullable: false, validations: {
502
505
  isIn: [['member', 'import', 'system', 'api', 'admin']]
@@ -634,7 +637,10 @@ module.exports = {
634
637
  isIn: [['url', 'post', 'page', 'author', 'tag']]
635
638
  }
636
639
  },
637
- attribution_url: {type: 'string', maxlength: 2000, nullable: true}
640
+ attribution_url: {type: 'string', maxlength: 2000, nullable: true},
641
+ referrer_source: {type: 'string', maxlength: 191, nullable: true},
642
+ referrer_medium: {type: 'string', maxlength: 191, nullable: true},
643
+ referrer_url: {type: 'string', maxlength: 2000, nullable: true}
638
644
  },
639
645
  offer_redemptions: {
640
646
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
@@ -829,5 +835,19 @@ module.exports = {
829
835
  finished_at: {type: 'dateTime', nullable: true},
830
836
  created_at: {type: 'dateTime', nullable: false},
831
837
  updated_at: {type: 'dateTime', nullable: true}
838
+ },
839
+ link_redirects: {
840
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
841
+ from: {type: 'string', maxlength: 2000, nullable: false},
842
+ to: {type: 'string', maxlength: 2000, nullable: false},
843
+ post_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'posts.id', setNullDelete: true},
844
+ created_at: {type: 'dateTime', nullable: false},
845
+ updated_at: {type: 'dateTime', nullable: true}
846
+ },
847
+ members_link_click_events: {
848
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
849
+ member_id: {type: 'string', maxlength: 24, nullable: false, references: 'members.id', cascadeDelete: true},
850
+ link_id: {type: 'string', maxlength: 24, nullable: false, references: 'link_redirects.id', cascadeDelete: true},
851
+ created_at: {type: 'dateTime', nullable: false}
832
852
  }
833
853
  };
@@ -0,0 +1,65 @@
1
+ const ghostBookshelf = require('./base');
2
+ const urlUtils = require('../../shared/url-utils');
3
+
4
+ const LinkRedirect = ghostBookshelf.Model.extend({
5
+ tableName: 'link_redirects',
6
+
7
+ post() {
8
+ return this.belongsTo('Post', 'post_id');
9
+ },
10
+
11
+ formatOnWrite(attrs) {
12
+ if (attrs.to) {
13
+ attrs.to = urlUtils.absoluteToTransformReady(attrs.to);
14
+ }
15
+
16
+ return attrs;
17
+ },
18
+
19
+ parse() {
20
+ const attrs = ghostBookshelf.Model.prototype.parse.apply(this, arguments);
21
+
22
+ if (attrs.to) {
23
+ attrs.to = urlUtils.transformReadyToAbsolute(attrs.to);
24
+ }
25
+
26
+ return attrs;
27
+ }
28
+ }, {
29
+ orderDefaultRaw(options) {
30
+ if (options.withRelated && options.withRelated.includes('count.clicks')) {
31
+ return '`count__clicks` DESC, `to` DESC';
32
+ }
33
+ return '`to` DESC';
34
+ },
35
+
36
+ permittedOptions(methodName) {
37
+ let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
38
+ const validOptions = {
39
+ findAll: ['filter', 'columns', 'withRelated']
40
+ };
41
+
42
+ if (validOptions[methodName]) {
43
+ options = options.concat(validOptions[methodName]);
44
+ }
45
+
46
+ return options;
47
+ },
48
+
49
+ countRelations() {
50
+ return {
51
+ clicks(modelOrCollection) {
52
+ modelOrCollection.query('columns', 'link_redirects.*', (qb) => {
53
+ qb.countDistinct('members_link_click_events.member_id')
54
+ .from('members_link_click_events')
55
+ .whereRaw('link_redirects.id = members_link_click_events.link_id')
56
+ .as('count__clicks');
57
+ });
58
+ }
59
+ };
60
+ }
61
+ });
62
+
63
+ module.exports = {
64
+ LinkRedirect: ghostBookshelf.model('LinkRedirect', LinkRedirect)
65
+ };
@@ -0,0 +1,26 @@
1
+ const errors = require('@tryghost/errors');
2
+ const ghostBookshelf = require('./base');
3
+
4
+ const MemberLinkClickEvent = ghostBookshelf.Model.extend({
5
+ tableName: 'members_link_click_events',
6
+
7
+ link() {
8
+ return this.belongsTo('LinkRedirect', 'link_id');
9
+ },
10
+
11
+ member() {
12
+ return this.belongsTo('Member', 'member_id', 'id');
13
+ }
14
+ }, {
15
+ async edit() {
16
+ throw new errors.IncorrectUsageError({message: 'Cannot edit MemberLinkClickEvent'});
17
+ },
18
+
19
+ async destroy() {
20
+ throw new errors.IncorrectUsageError({message: 'Cannot destroy MemberLinkClickEvent'});
21
+ }
22
+ });
23
+
24
+ module.exports = {
25
+ MemberLinkClickEvent: ghostBookshelf.model('MemberLinkClickEvent', MemberLinkClickEvent)
26
+ };
@@ -26,7 +26,11 @@ const messages = {
26
26
  expectedPublishedAtInFuture: 'Date must be at least {cannotScheduleAPostBeforeInMinutes} minutes in the future.',
27
27
  untitled: '(Untitled)',
28
28
  notEnoughPermission: 'You do not have permission to perform this action',
29
- invalidNewsletter: 'The newsletter parameter doesn\'t match any active newsletter.'
29
+ invalidNewsletter: 'The newsletter parameter doesn\'t match any active newsletter.',
30
+ invalidMobiledocStructure: 'Invalid mobiledoc structure.',
31
+ invalidMobiledocStructureHelp: 'https://ghost.org/docs/publishing/',
32
+ invalidLexicalStructure: 'Invalid lexical structure.',
33
+ invalidLexicalStructureHelp: 'https://ghost.org/docs/publishing/'
30
34
  };
31
35
 
32
36
  const MOBILEDOC_REVISIONS_COUNT = 10;
@@ -130,6 +134,7 @@ Post = ghostBookshelf.Model.extend({
130
134
  // transform URLs from __GHOST_URL__ to absolute
131
135
  [
132
136
  'mobiledoc',
137
+ 'lexical',
133
138
  'html',
134
139
  'plaintext',
135
140
  'custom_excerpt',
@@ -158,6 +163,7 @@ Post = ghostBookshelf.Model.extend({
158
163
  cardTransformers: mobiledocLib.cards
159
164
  }
160
165
  },
166
+ lexical: 'lexicalToTransformReady',
161
167
  html: 'htmlToTransformReady',
162
168
  plaintext: 'plaintextToTransformReady',
163
169
  custom_excerpt: 'htmlToTransformReady',
@@ -623,7 +629,7 @@ Post = ghostBookshelf.Model.extend({
623
629
  this.set('html', mobiledocLib.mobiledocHtmlRenderer.render(JSON.parse(this.get('mobiledoc'))));
624
630
  } catch (err) {
625
631
  throw new errors.ValidationError({
626
- message: 'Invalid mobiledoc structure.',
632
+ message: tpl(messages.invalidMobiledocStructure),
627
633
  help: 'https://ghost.org/docs/publishing/'
628
634
  });
629
635
  }
@@ -644,9 +650,10 @@ Post = ghostBookshelf.Model.extend({
644
650
  this.set('html', lexicalLib.lexicalHtmlRenderer.render(this.get('lexical')));
645
651
  } catch (err) {
646
652
  throw new errors.ValidationError({
647
- message: 'Invalid lexical structure.',
648
- help: 'https://ghost.org/docs/publishing/',
649
- property: 'lexical'
653
+ message: tpl(messages.invalidLexicalStructure),
654
+ context: err.message,
655
+ property: 'lexical',
656
+ help: tpl(messages.invalidLexicalStructureHelp)
650
657
  });
651
658
  }
652
659
  }
@@ -1338,6 +1345,15 @@ Post = ghostBookshelf.Model.extend({
1338
1345
  .whereRaw('posts.id = members_subscription_created_events.attribution_id')
1339
1346
  .as('count__conversions');
1340
1347
  });
1348
+ },
1349
+ clicks(modelOrCollection) {
1350
+ modelOrCollection.query('columns', 'posts.*', (qb) => {
1351
+ qb.countDistinct('members_link_click_events.member_id')
1352
+ .from('members_link_click_events')
1353
+ .join('link_redirects', 'members_link_click_events.link_id', 'link_redirects.id')
1354
+ .whereRaw('posts.id = link_redirects.post_id')
1355
+ .as('count__clicks');
1356
+ });
1341
1357
  }
1342
1358
  };
1343
1359
  }
@@ -13,7 +13,7 @@ const configService = require('../../../shared/config');
13
13
  const settingsCache = require('../../../shared/settings-cache');
14
14
 
15
15
  const messages = {
16
- error: 'The email service was unable to send an email batch.'
16
+ error: 'The email service received an error from mailgun and was unable to send.'
17
17
  };
18
18
 
19
19
  const mailgunClient = new MailgunClient({config: configService, settings: settingsCache});
@@ -251,11 +251,13 @@ module.exports = {
251
251
  const response = await mailgunClient.send(emailData, recipientData, replacements);
252
252
  debug(`sent message (${Date.now() - startTime}ms)`);
253
253
  return response;
254
- } catch (error) {
255
- // REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#errors
254
+ } catch (err) {
256
255
  let ghostError = new errors.EmailError({
257
- err: error,
258
- context: tpl(messages.error),
256
+ err,
257
+ message: tpl(messages.error),
258
+ context: `Mailgun Error ${err.error.status}: ${err.error.details}`,
259
+ // REF: possible mailgun errors https://documentation.mailgun.com/en/latest/api-intro.html#errors
260
+ help: `https://ghost.org/docs/newsletters/#bulk-email-configuration`,
259
261
  code: 'BULK_EMAIL_SEND_FAILED'
260
262
  });
261
263
 
@@ -0,0 +1,88 @@
1
+ const LinkRedirect = require('@tryghost/link-redirects').LinkRedirect;
2
+ const ObjectID = require('bson-objectid').default;
3
+
4
+ module.exports = class LinkRedirectRepository {
5
+ /** @type {Object} */
6
+ #LinkRedirect;
7
+ /** @type {Object} */
8
+ #urlUtils;
9
+
10
+ /**
11
+ * @param {object} deps
12
+ * @param {object} deps.LinkRedirect Bookshelf Model
13
+ * @param {object} deps.urlUtils
14
+ */
15
+ constructor(deps) {
16
+ this.#LinkRedirect = deps.LinkRedirect;
17
+ this.#urlUtils = deps.urlUtils;
18
+ }
19
+
20
+ /**
21
+ * @param {InstanceType<LinkRedirect>} linkRedirect
22
+ * @returns {Promise<void>}
23
+ */
24
+ async save(linkRedirect) {
25
+ const model = await this.#LinkRedirect.add({
26
+ // Only store the parthname (no support for variable query strings)
27
+ from: this.stripSubdirectoryFromPath(linkRedirect.from.pathname),
28
+ to: linkRedirect.to.href
29
+ }, {});
30
+
31
+ linkRedirect.link_id = ObjectID.createFromHexString(model.id);
32
+ }
33
+
34
+ #trimLeadingSlash(url) {
35
+ return url.replace(/^\//, '');
36
+ }
37
+
38
+ fromModel(model) {
39
+ return new LinkRedirect({
40
+ id: model.id,
41
+ from: new URL(this.#trimLeadingSlash(model.get('from')), this.#urlUtils.urlFor('home', true)),
42
+ to: new URL(model.get('to'))
43
+ });
44
+ }
45
+
46
+ async getAll(options) {
47
+ const collection = await this.#LinkRedirect.findAll(options);
48
+
49
+ const result = [];
50
+
51
+ for (const model of collection.models) {
52
+ result.push(this.fromModel(model));
53
+ }
54
+
55
+ return result;
56
+ }
57
+
58
+ /**
59
+ *
60
+ * @param {URL} url
61
+ * @returns {Promise<InstanceType<LinkRedirect>|undefined>} linkRedirect
62
+ */
63
+ async getByURL(url) {
64
+ // Strip subdirectory from path
65
+ const from = this.stripSubdirectoryFromPath(url.pathname);
66
+
67
+ const linkRedirect = await this.#LinkRedirect.findOne({
68
+ from
69
+ }, {});
70
+
71
+ if (linkRedirect) {
72
+ return this.fromModel(linkRedirect);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Convert root relative URLs to subdirectory relative URLs
78
+ */
79
+ stripSubdirectoryFromPath(path) {
80
+ // Bit weird, but only way to do it with the urlUtils atm
81
+
82
+ // First convert path to an absolute path
83
+ const absolute = this.#urlUtils.relativeToAbsolute(path);
84
+
85
+ // Then convert it to a relative path, but without subdirectory
86
+ return this.#urlUtils.absoluteToRelative(absolute, {withoutSubdirectory: true});
87
+ }
88
+ };
@@ -1,4 +1,5 @@
1
1
  const urlUtils = require('../../../shared/url-utils');
2
+ const LinkRedirectRepository = require('./LinkRedirectRepository');
2
3
 
3
4
  class LinkRedirectsServiceWrapper {
4
5
  async init() {
@@ -8,21 +9,18 @@ class LinkRedirectsServiceWrapper {
8
9
  }
9
10
 
10
11
  // Wire up all the dependencies
12
+ const models = require('../../models');
13
+
11
14
  const {LinkRedirectsService} = require('@tryghost/link-redirects');
12
15
 
13
- const store = [];
16
+ this.linkRedirectRepository = new LinkRedirectRepository({
17
+ LinkRedirect: models.LinkRedirect,
18
+ urlUtils
19
+ });
20
+
14
21
  // Expose the service
15
22
  this.service = new LinkRedirectsService({
16
- linkRedirectRepository: {
17
- async save(linkRedirect) {
18
- store.push(linkRedirect);
19
- },
20
- async getByURL(url) {
21
- return store.find((link) => {
22
- return link.from.pathname === url.pathname;
23
- });
24
- }
25
- },
23
+ linkRedirectRepository: this.linkRedirectRepository,
26
24
  config: {
27
25
  baseURL: new URL(urlUtils.getSiteUrl())
28
26
  }
@@ -0,0 +1,69 @@
1
+ const {LinkClick} = require('@tryghost/link-tracking');
2
+ const ObjectID = require('bson-objectid').default;
3
+
4
+ module.exports = class LinkClickRepository {
5
+ /** @type {Object} */
6
+ #MemberLinkClickEventModel;
7
+
8
+ /** @type {Object} */
9
+ #MemberLinkClickEvent;
10
+
11
+ /** @type {object} */
12
+ #Member;
13
+
14
+ /** @type {object} */
15
+ #DomainEvents;
16
+
17
+ /**
18
+ * @param {object} deps
19
+ * @param {object} deps.MemberLinkClickEventModel Bookshelf Model
20
+ * @param {object} deps.Member Bookshelf Model
21
+ * @param {object} deps.MemberLinkClickEvent Event
22
+ * @param {object} deps.DomainEvents
23
+ */
24
+ constructor(deps) {
25
+ this.#MemberLinkClickEventModel = deps.MemberLinkClickEventModel;
26
+ this.#Member = deps.Member;
27
+ this.#MemberLinkClickEvent = deps.MemberLinkClickEvent;
28
+ this.#DomainEvents = deps.DomainEvents;
29
+ }
30
+
31
+ async getAll(options) {
32
+ const collection = await this.#MemberLinkClickEventModel.findAll(options);
33
+
34
+ const result = [];
35
+
36
+ for (const model of collection.models) {
37
+ const member = await this.#Member.findOne({id: model.get('member_id')});
38
+ result.push(new LinkClick({
39
+ link_id: model.get('link_id'),
40
+ member_uuid: member.get('uuid')
41
+ }));
42
+ }
43
+
44
+ return result;
45
+ }
46
+
47
+ /**
48
+ * @param {LinkClick} linkClick
49
+ * @returns {Promise<void>}
50
+ */
51
+ async save(linkClick) {
52
+ // Convert uuid to id
53
+ const member = await this.#Member.findOne({uuid: linkClick.member_uuid});
54
+ if (!member) {
55
+ return;
56
+ }
57
+
58
+ const model = await this.#MemberLinkClickEventModel.add({
59
+ // Only store the parthname (no support for variable query strings)
60
+ link_id: linkClick.link_id.toHexString(),
61
+ member_id: member.id
62
+ }, {});
63
+
64
+ linkClick.event_id = ObjectID.createFromHexString(model.id);
65
+
66
+ // Dispatch event
67
+ this.#DomainEvents.dispatch(this.#MemberLinkClickEvent.create({memberId: member.id, memberLastSeenAt: member.get('last_seen_at'), linkId: linkClick.link_id.toHexString()}, new Date()));
68
+ }
69
+ };