ghost 5.20.0 → 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 (139) hide show
  1. package/components/{tryghost-adapter-manager-5.20.0.tgz → tryghost-adapter-manager-5.21.0.tgz} +0 -0
  2. package/components/{tryghost-api-framework-5.20.0.tgz → tryghost-api-framework-5.21.0.tgz} +0 -0
  3. package/components/{tryghost-api-version-compatibility-service-5.20.0.tgz → 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.20.0.tgz → tryghost-bootstrap-socket-5.21.0.tgz} +0 -0
  6. package/components/{tryghost-constants-5.20.0.tgz → 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.20.0.tgz → 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.20.0.tgz → tryghost-email-content-generator-5.21.0.tgz} +0 -0
  13. package/components/{tryghost-express-dynamic-redirects-5.20.0.tgz → 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.20.0.tgz → tryghost-html-to-plaintext-5.21.0.tgz} +0 -0
  16. package/components/{tryghost-job-manager-5.20.0.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.20.0.tgz → 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.20.0.tgz → 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.20.0.tgz → 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.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.20.0.tgz → 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.20.0.tgz → tryghost-mw-session-from-token-5.21.0.tgz} +0 -0
  39. package/components/{tryghost-mw-update-user-last-seen-5.20.0.tgz → 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.20.0.tgz → tryghost-oembed-service-5.21.0.tgz} +0 -0
  42. package/components/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.20.0.tgz → tryghost-security-5.21.0.tgz} +0 -0
  45. package/components/{tryghost-session-service-5.20.0.tgz → 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.20.0.tgz → tryghost-staff-service-5.21.0.tgz} +0 -0
  48. package/components/{tryghost-stats-service-5.20.0.tgz → 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.21.0.tgz +0 -0
  51. package/components/{tryghost-verification-trigger-5.20.0.tgz → 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.d245b085ad1efed4ee76.js → chunk.143.9cddfa7bd1a8b9cf3d4b.js} +5 -5
  55. package/core/built/admin/assets/{chunk.178.c45f56ea31775e509497.js → chunk.178.6de14cfdb28df721b66e.js} +4 -4
  56. package/core/built/admin/assets/{chunk.613.c4d89dc2d28c1b20348f.js → chunk.613.695f31829550fb00d43c.js} +351 -420
  57. package/core/built/admin/assets/{chunk.613.c4d89dc2d28c1b20348f.js.LICENSE.txt → chunk.613.695f31829550fb00d43c.js.LICENSE.txt} +0 -0
  58. package/core/built/admin/assets/{ghost-07e4bbf5029630b3c8a8a50c4b9f2d9e.js → ghost-192fee3b46a193df1e65c49a67a7d694.js} +308 -237
  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-518b03b02df9a55706d150627ef1004f.js → vendor-26cca1d4d56660dc6e915a12ccc3b330.js} +1038 -1003
  62. package/core/built/admin/index.html +6 -6
  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 +2 -1
  66. package/core/server/api/endpoints/tiers-public.js +2 -14
  67. package/core/server/api/endpoints/tiers.js +5 -51
  68. package/core/server/api/endpoints/utils/serializers/input/posts.js +1 -1
  69. package/core/server/api/endpoints/utils/serializers/input/settings.js +1 -0
  70. package/core/server/api/endpoints/utils/serializers/input/tiers.js +18 -27
  71. package/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js +42 -0
  72. package/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +5 -4
  73. package/core/server/api/endpoints/utils/serializers/output/tiers.js +15 -55
  74. package/core/server/data/db/backup.js +17 -10
  75. package/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js +20 -0
  76. package/core/server/data/migrations/versions/5.21/2022-10-25-12-05-backfill-missed-products-columns.js +35 -0
  77. package/core/server/data/migrations/versions/5.21/2022-10-26-04-49-add-batch-id-members-created-events.js +7 -0
  78. package/core/server/data/migrations/versions/5.21/2022-10-26-04-49-add-batch-id-subscription-created-events.js +7 -0
  79. package/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js +72 -0
  80. package/core/server/data/migrations/versions/5.21/2022-10-26-09-32-add-feedback-enabled-column-to-emails.js +7 -0
  81. package/core/server/data/migrations/versions/5.21/2022-10-27-09-50-add-member-track-source-setting.js +8 -0
  82. package/core/server/data/schema/default-settings/default-settings.json +8 -0
  83. package/core/server/data/schema/fixtures/fixture-manager.js +16 -14
  84. package/core/server/data/schema/schema.js +5 -2
  85. package/core/server/models/base/plugins/crud.js +12 -0
  86. package/core/server/models/email.js +1 -0
  87. package/core/server/models/member-click-event.js +13 -0
  88. package/core/server/models/member-created-event.js +23 -0
  89. package/core/server/models/member-paid-subscription-event.js +4 -0
  90. package/core/server/models/member.js +6 -0
  91. package/core/server/models/post.js +1 -1
  92. package/core/server/models/subscription-created-event.js +7 -0
  93. package/core/server/services/mega/feedback-buttons.js +87 -16
  94. package/core/server/services/mega/mega.js +1 -0
  95. package/core/server/services/mega/template.js +3 -0
  96. package/core/server/services/member-attribution/index.js +3 -1
  97. package/core/server/services/members/api.js +2 -0
  98. package/core/server/services/members/service.js +8 -1
  99. package/core/server/services/newsletters/index.js +3 -1
  100. package/core/server/services/newsletters/service.js +11 -1
  101. package/core/server/services/tiers/TierRepository.js +116 -0
  102. package/core/server/services/tiers/index.js +1 -0
  103. package/core/server/services/tiers/service.js +32 -0
  104. package/core/shared/config/defaults.json +1 -1
  105. package/core/shared/labs.js +6 -7
  106. package/ghost.js +1 -0
  107. package/package.json +115 -112
  108. package/yarn.lock +1170 -1097
  109. package/components/tryghost-audience-feedback-5.20.0.tgz +0 -0
  110. package/components/tryghost-custom-theme-settings-service-5.20.0.tgz +0 -0
  111. package/components/tryghost-email-analytics-provider-mailgun-5.20.0.tgz +0 -0
  112. package/components/tryghost-email-analytics-service-5.20.0.tgz +0 -0
  113. package/components/tryghost-extract-api-key-5.20.0.tgz +0 -0
  114. package/components/tryghost-link-redirects-5.20.0.tgz +0 -0
  115. package/components/tryghost-link-replacer-5.20.0.tgz +0 -0
  116. package/components/tryghost-link-tracking-5.20.0.tgz +0 -0
  117. package/components/tryghost-mailgun-client-5.20.0.tgz +0 -0
  118. package/components/tryghost-member-analytics-service-5.20.0.tgz +0 -0
  119. package/components/tryghost-member-attribution-5.20.0.tgz +0 -0
  120. package/components/tryghost-member-events-5.20.0.tgz +0 -0
  121. package/components/tryghost-members-api-5.20.0.tgz +0 -0
  122. package/components/tryghost-members-csv-5.20.0.tgz +0 -0
  123. package/components/tryghost-members-events-service-5.20.0.tgz +0 -0
  124. package/components/tryghost-members-importer-5.20.0.tgz +0 -0
  125. package/components/tryghost-members-payments-5.20.0.tgz +0 -0
  126. package/components/tryghost-members-ssr-5.20.0.tgz +0 -0
  127. package/components/tryghost-members-stripe-service-5.20.0.tgz +0 -0
  128. package/components/tryghost-minifier-5.20.0.tgz +0 -0
  129. package/components/tryghost-mw-cache-control-5.20.0.tgz +0 -0
  130. package/components/tryghost-mw-error-handler-5.20.0.tgz +0 -0
  131. package/components/tryghost-mw-vhost-5.20.0.tgz +0 -0
  132. package/components/tryghost-package-json-5.20.0.tgz +0 -0
  133. package/components/tryghost-referrers-5.20.0.tgz +0 -0
  134. package/components/tryghost-settings-path-manager-5.20.0.tgz +0 -0
  135. package/components/tryghost-tiers-5.20.0.tgz +0 -0
  136. package/components/tryghost-update-check-service-5.20.0.tgz +0 -0
  137. package/components/tryghost-version-notifications-data-service-5.20.0.tgz +0 -0
  138. package/core/built/admin/assets/ghost-dark-363185f15c782b4b8394c5db23984e7f.css +0 -1
  139. package/core/built/admin/assets/ghost-fd0480352bf27e013b2b00a1bf9ffe84.css +0 -1
@@ -0,0 +1,72 @@
1
+ const _ = require('lodash');
2
+ const logging = require('@tryghost/logging');
3
+ const ObjectId = require('bson-objectid').default;
4
+ const {createTransactionalMigration} = require('../../utils');
5
+ const DatabaseInfo = require('@tryghost/database-info');
6
+
7
+ // This migration links together members_created_events and members_subscription_created_events
8
+
9
+ module.exports = createTransactionalMigration(
10
+ async function up(knex) {
11
+ if (DatabaseInfo.isSQLite(knex)) {
12
+ logging.info('Skipped linking members_created_events and members_subscription_created_events on SQLite');
13
+ return;
14
+ }
15
+
16
+ // All events that happened within 15 minutes of each other will be linked
17
+ const rows = await knex('members_created_events as m')
18
+ .select('m.id as m_id', 's.id as s_id', 'm.member_id as member_id', 's.subscription_id as subscription_id')
19
+ .join('members_subscription_created_events AS s', 's.member_id', 'm.member_id')
20
+ .whereRaw('TIMESTAMPDIFF(MINUTE, s.created_at, m.created_at) between -15 and 15');
21
+
22
+ if (!rows.length) {
23
+ logging.info('Did not find linkable members_created_events and members_subscription_created_events');
24
+ return;
25
+ }
26
+
27
+ // Attach a unique id to each row
28
+ for (const row of rows) { // eslint-disable-line no-restricted-syntax
29
+ row.batch_id = ObjectId().toHexString();
30
+ }
31
+
32
+ // Create batches (insertBatch doesn't support the onConflict option)
33
+ const batches = _.chunk(rows, 1000);
34
+
35
+ for (const batch of batches) { // eslint-disable-line no-restricted-syntax
36
+ // Update the members_created_events table using INSERT ON DUPLICATE KEY UPDATE trick
37
+ const response1 = await knex('members_created_events').insert(batch.map((r) => {
38
+ return {
39
+ id: r.m_id,
40
+ batch_id: r.batch_id,
41
+ member_id: r.member_id, // added to make the insert work
42
+ source: '', // added to make the insert work
43
+ created_at: knex.raw('NOW()') // added to make the insert work
44
+ };
45
+ })).onConflict('id').merge(['batch_id']);
46
+
47
+ if (response1[0] !== 0) {
48
+ logging.error(`Inserted ${response1[0]} members_created_events, expected 0`);
49
+ throw new Error('Rolling back');
50
+ }
51
+
52
+ const response2 = await knex('members_subscription_created_events').insert(batch.map((r) => {
53
+ return {
54
+ id: r.s_id,
55
+ batch_id: r.batch_id,
56
+ member_id: r.member_id, // added to make the insert work
57
+ subscription_id: r.subscription_id, // added to make the insert work
58
+ created_at: knex.raw('NOW()') // added to make the insert work
59
+ };
60
+ })).onConflict('id').merge(['batch_id']);
61
+
62
+ if (response2[0] !== 0) {
63
+ logging.error(`Inserted ${response1[0]} members_subscription_created_events, expected 0`);
64
+ throw new Error('Rolling back');
65
+ }
66
+ }
67
+ logging.info(`Linked ${rows.length} members_created_events and members_subscription_created_events`);
68
+ },
69
+ async function down() {
70
+ // noop
71
+ }
72
+ );
@@ -0,0 +1,7 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('emails', 'feedback_enabled', {
4
+ type: 'boolean',
5
+ nullable: false,
6
+ defaultTo: false
7
+ });
@@ -0,0 +1,8 @@
1
+ const {addSetting} = require('../../utils');
2
+
3
+ module.exports = addSetting({
4
+ key: 'members_track_sources',
5
+ value: 'true',
6
+ type: 'boolean',
7
+ group: 'members'
8
+ });
@@ -295,6 +295,14 @@
295
295
  "members_yearly_price_id": {
296
296
  "defaultValue": null,
297
297
  "type": "string"
298
+ },
299
+ "members_track_sources": {
300
+ "defaultValue": "true",
301
+ "validations": {
302
+ "isEmpty": false,
303
+ "isIn": [["true", "false"]]
304
+ },
305
+ "type": "boolean"
298
306
  }
299
307
  },
300
308
  "portal": {
@@ -1,5 +1,4 @@
1
1
  const _ = require('lodash');
2
- const Promise = require('bluebird');
3
2
  const logging = require('@tryghost/logging');
4
3
  const {sequence} = require('@tryghost/promise');
5
4
 
@@ -83,16 +82,16 @@ class FixtureManager {
83
82
  const userRolesRelation = this.fixtures.relations.find(r => r.from.relation === 'roles');
84
83
  await this.addFixturesForRelation(userRolesRelation, localOptions);
85
84
 
86
- await Promise.mapSeries(this.fixtures.models.filter(m => !['User', 'Role'].includes(m.name)), (model) => {
85
+ await sequence(this.fixtures.models.filter(m => !['User', 'Role'].includes(m.name)).map(model => () => {
87
86
  logging.info('Model: ' + model.name);
88
87
 
89
88
  return this.addFixturesForModel(model, localOptions);
90
- });
89
+ }));
91
90
 
92
- await Promise.mapSeries(this.fixtures.relations.filter(r => r.from.relation !== 'roles'), (relation) => {
91
+ await sequence(this.fixtures.relations.filter(r => r.from.relation !== 'roles').map(relation => () => {
93
92
  logging.info('Relation: ' + relation.from.model + ' to ' + relation.to.model);
94
93
  return this.addFixturesForRelation(relation, localOptions);
95
- });
94
+ }));
96
95
  }
97
96
 
98
97
  /*
@@ -191,12 +190,15 @@ class FixtureManager {
191
190
  fetchRelationData(relation, options) {
192
191
  const fromOptions = _.extend({}, options, {withRelated: [relation.from.relation]});
193
192
 
194
- const props = {
195
- from: models[relation.from.model].findAll(fromOptions),
196
- to: models[relation.to.model].findAll(options)
197
- };
193
+ const fromRelations = models[relation.from.model].findAll(fromOptions);
194
+ const toRelations = models[relation.to.model].findAll(options);
198
195
 
199
- return Promise.props(props);
196
+ return Promise.all([fromRelations, toRelations]).then(([from, to]) => {
197
+ return {
198
+ from: from,
199
+ to: to
200
+ };
201
+ });
200
202
  }
201
203
 
202
204
  /**
@@ -223,7 +225,7 @@ class FixtureManager {
223
225
  });
224
226
  }
225
227
 
226
- const results = await Promise.mapSeries(modelFixture.entries, async (entry) => {
228
+ const results = await sequence(modelFixture.entries.map(entry => async () => {
227
229
  let data = {};
228
230
 
229
231
  // CASE: if id is specified, only query by id
@@ -243,7 +245,7 @@ class FixtureManager {
243
245
  if (!found) {
244
246
  return models[modelFixture.name].add(entry, options);
245
247
  }
246
- });
248
+ }));
247
249
 
248
250
  return {expected: modelFixture.entries.length, done: _.compact(results).length};
249
251
  }
@@ -308,12 +310,12 @@ class FixtureManager {
308
310
  }
309
311
 
310
312
  async removeFixturesForModel(modelFixture, options) {
311
- const results = await Promise.mapSeries(modelFixture.entries, async (entry) => {
313
+ const results = await sequence(modelFixture.entries.map(entry => async () => {
312
314
  const found = models[modelFixture.name].findOne(entry.id ? {id: entry.id} : entry, options);
313
315
  if (found) {
314
316
  return models[modelFixture.name].destroy(_.extend(options, {id: found.id}));
315
317
  }
316
- });
318
+ }));
317
319
 
318
320
  return {expected: modelFixture.entries.length, done: results.length};
319
321
  }
@@ -517,7 +517,8 @@ module.exports = {
517
517
  type: 'string', maxlength: 50, nullable: false, validations: {
518
518
  isIn: [['member', 'import', 'system', 'api', 'admin']]
519
519
  }
520
- }
520
+ },
521
+ batch_id: {type: 'string', maxlength: 24, nullable: true}
521
522
  },
522
523
  members_cancel_events: {
523
524
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
@@ -697,7 +698,8 @@ module.exports = {
697
698
  attribution_url: {type: 'string', maxlength: 2000, nullable: true},
698
699
  referrer_source: {type: 'string', maxlength: 191, nullable: true},
699
700
  referrer_medium: {type: 'string', maxlength: 191, nullable: true},
700
- referrer_url: {type: 'string', maxlength: 2000, nullable: true}
701
+ referrer_url: {type: 'string', maxlength: 2000, nullable: true},
702
+ batch_id: {type: 'string', maxlength: 24, nullable: true}
701
703
  },
702
704
  offer_redemptions: {
703
705
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
@@ -783,6 +785,7 @@ module.exports = {
783
785
  plaintext: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
784
786
  track_opens: {type: 'boolean', nullable: false, defaultTo: false},
785
787
  track_clicks: {type: 'boolean', nullable: false, defaultTo: false},
788
+ feedback_enabled: {type: 'boolean', nullable: false, defaultTo: false},
786
789
  submitted_at: {type: 'dateTime', nullable: false},
787
790
  newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'},
788
791
  created_at: {type: 'dateTime', nullable: false},
@@ -108,6 +108,18 @@ module.exports = function (Bookshelf) {
108
108
  options.order = this.orderDefaultOptions();
109
109
  }
110
110
 
111
+ if (options.selectRaw) {
112
+ itemCollection.query((qb) => {
113
+ qb.select(qb.client.raw(options.selectRaw));
114
+ });
115
+ }
116
+
117
+ if (options.whereRaw) {
118
+ itemCollection.query((qb) => {
119
+ qb.whereRaw(options.whereRaw);
120
+ });
121
+ }
122
+
111
123
  const response = await itemCollection.fetchPage(options);
112
124
  // Attributes are being filtered here, so they are not leaked into calling layer
113
125
  // where models are serialized to json and do not do more filtering.
@@ -11,6 +11,7 @@ const Email = ghostBookshelf.Model.extend({
11
11
  recipient_filter: 'status:-free',
12
12
  track_opens: false,
13
13
  track_clicks: false,
14
+ feedback_enabled: false,
14
15
  delivered_count: 0,
15
16
  opened_count: 0,
16
17
  failed_count: 0
@@ -42,6 +42,19 @@ const MemberClickEvent = ghostBookshelf.Model.extend({
42
42
 
43
43
  async destroy() {
44
44
  throw new errors.IncorrectUsageError({message: 'Cannot destroy MemberClickEvent'});
45
+ },
46
+
47
+ permittedOptions(methodName) {
48
+ let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
49
+ const validOptions = {
50
+ findPage: ['selectRaw', 'whereRaw']
51
+ };
52
+
53
+ if (validOptions[methodName]) {
54
+ options = options.concat(validOptions[methodName]);
55
+ }
56
+
57
+ return options;
45
58
  }
46
59
  });
47
60
 
@@ -8,6 +8,13 @@ const MemberCreatedEvent = ghostBookshelf.Model.extend({
8
8
  return this.belongsTo('Member', 'member_id', 'id');
9
9
  },
10
10
 
11
+ /**
12
+ * The subscription created event that happend at the same time (if any)
13
+ */
14
+ subscriptionCreatedEvent() {
15
+ return this.belongsTo('SubscriptionCreatedEvent', 'batch_id', 'batch_id');
16
+ },
17
+
11
18
  postAttribution() {
12
19
  return this.belongsTo('Post', 'attribution_id', 'id');
13
20
  },
@@ -18,6 +25,22 @@ const MemberCreatedEvent = ghostBookshelf.Model.extend({
18
25
 
19
26
  tagAttribution() {
20
27
  return this.belongsTo('Tag', 'attribution_id', 'id');
28
+ },
29
+
30
+ filterRelations() {
31
+ return {
32
+ subscriptionCreatedEvent: {
33
+ // Mongo-knex doesn't support belongsTo relations
34
+ tableName: 'members_subscription_created_events',
35
+ tableNameAs: 'subscriptionCreatedEvent',
36
+ type: 'manyToMany',
37
+ joinTable: 'members_created_events',
38
+ joinFrom: 'id',
39
+ joinToForeign: 'batch_id',
40
+ joinTo: 'batch_id',
41
+ joinType: 'leftJoin'
42
+ }
43
+ };
21
44
  }
22
45
  }, {
23
46
  async edit() {
@@ -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
  },
@@ -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
  },
@@ -1399,7 +1399,7 @@ Post = ghostBookshelf.Model.extend({
1399
1399
  qb.count('*')
1400
1400
  .from('members_feedback')
1401
1401
  .whereRaw('posts.id = members_feedback.post_id AND members_feedback.score = 0')
1402
- .as('count__positive_feedback');
1402
+ .as('count__negative_feedback');
1403
1403
  });
1404
1404
  },
1405
1405
  positive_feedback(modelOrCollection) {
@@ -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
  },
@@ -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
 
@@ -198,6 +199,7 @@ function createApiInstance(config) {
198
199
  MemberFeedback: models.MemberFeedback
199
200
  },
200
201
  stripeAPIService: stripeService.api,
202
+ tiersService: tiersService,
201
203
  offersAPI: offersService.api,
202
204
  labsService: labsService,
203
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