ghost 5.20.0 → 5.22.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 (144) hide show
  1. package/components/{tryghost-adapter-manager-5.20.0.tgz → tryghost-adapter-manager-5.22.0.tgz} +0 -0
  2. package/components/{tryghost-api-framework-5.20.0.tgz → tryghost-api-framework-5.22.0.tgz} +0 -0
  3. package/components/{tryghost-api-version-compatibility-service-5.20.0.tgz → tryghost-api-version-compatibility-service-5.22.0.tgz} +0 -0
  4. package/components/tryghost-audience-feedback-5.22.0.tgz +0 -0
  5. package/components/tryghost-bootstrap-socket-5.22.0.tgz +0 -0
  6. package/components/{tryghost-constants-5.20.0.tgz → tryghost-constants-5.22.0.tgz} +0 -0
  7. package/components/tryghost-custom-theme-settings-service-5.22.0.tgz +0 -0
  8. package/components/tryghost-data-generator-5.22.0.tgz +0 -0
  9. package/components/tryghost-domain-events-5.22.0.tgz +0 -0
  10. package/components/tryghost-email-analytics-provider-mailgun-5.22.0.tgz +0 -0
  11. package/components/tryghost-email-analytics-service-5.22.0.tgz +0 -0
  12. package/components/{tryghost-email-content-generator-5.20.0.tgz → tryghost-email-content-generator-5.22.0.tgz} +0 -0
  13. package/components/{tryghost-express-dynamic-redirects-5.20.0.tgz → tryghost-express-dynamic-redirects-5.22.0.tgz} +0 -0
  14. package/components/tryghost-extract-api-key-5.22.0.tgz +0 -0
  15. package/components/{tryghost-html-to-plaintext-5.20.0.tgz → tryghost-html-to-plaintext-5.22.0.tgz} +0 -0
  16. package/components/{tryghost-job-manager-5.20.0.tgz → tryghost-job-manager-5.22.0.tgz} +0 -0
  17. package/components/tryghost-link-redirects-5.22.0.tgz +0 -0
  18. package/components/tryghost-link-replacer-5.22.0.tgz +0 -0
  19. package/components/tryghost-link-tracking-5.22.0.tgz +0 -0
  20. package/components/{tryghost-magic-link-5.20.0.tgz → tryghost-magic-link-5.22.0.tgz} +0 -0
  21. package/components/tryghost-mailgun-client-5.22.0.tgz +0 -0
  22. package/components/tryghost-member-analytics-service-5.22.0.tgz +0 -0
  23. package/components/tryghost-member-attribution-5.22.0.tgz +0 -0
  24. package/components/tryghost-member-events-5.22.0.tgz +0 -0
  25. package/components/tryghost-members-analytics-ingress-5.22.0.tgz +0 -0
  26. package/components/tryghost-members-api-5.22.0.tgz +0 -0
  27. package/components/tryghost-members-csv-5.22.0.tgz +0 -0
  28. package/components/tryghost-members-events-service-5.22.0.tgz +0 -0
  29. package/components/tryghost-members-importer-5.22.0.tgz +0 -0
  30. package/components/{tryghost-members-offers-5.20.0.tgz → tryghost-members-offers-5.22.0.tgz} +0 -0
  31. package/components/tryghost-members-payments-5.22.0.tgz +0 -0
  32. package/components/tryghost-members-ssr-5.22.0.tgz +0 -0
  33. package/components/tryghost-members-stripe-service-5.22.0.tgz +0 -0
  34. package/components/tryghost-minifier-5.22.0.tgz +0 -0
  35. package/components/{tryghost-mw-api-version-mismatch-5.20.0.tgz → tryghost-mw-api-version-mismatch-5.22.0.tgz} +0 -0
  36. package/components/{tryghost-mw-cache-control-5.20.0.tgz → tryghost-mw-cache-control-5.22.0.tgz} +0 -0
  37. package/components/tryghost-mw-error-handler-5.22.0.tgz +0 -0
  38. package/components/{tryghost-mw-session-from-token-5.20.0.tgz → tryghost-mw-session-from-token-5.22.0.tgz} +0 -0
  39. package/components/tryghost-mw-update-user-last-seen-5.22.0.tgz +0 -0
  40. package/components/tryghost-mw-vhost-5.22.0.tgz +0 -0
  41. package/components/{tryghost-oembed-service-5.20.0.tgz → tryghost-oembed-service-5.22.0.tgz} +0 -0
  42. package/components/tryghost-package-json-5.22.0.tgz +0 -0
  43. package/components/tryghost-referrers-5.22.0.tgz +0 -0
  44. package/components/{tryghost-security-5.20.0.tgz → tryghost-security-5.22.0.tgz} +0 -0
  45. package/components/{tryghost-session-service-5.20.0.tgz → tryghost-session-service-5.22.0.tgz} +0 -0
  46. package/components/tryghost-settings-path-manager-5.22.0.tgz +0 -0
  47. package/components/{tryghost-staff-service-5.20.0.tgz → tryghost-staff-service-5.22.0.tgz} +0 -0
  48. package/components/{tryghost-stats-service-5.20.0.tgz → tryghost-stats-service-5.22.0.tgz} +0 -0
  49. package/components/tryghost-tiers-5.22.0.tgz +0 -0
  50. package/components/tryghost-update-check-service-5.22.0.tgz +0 -0
  51. package/components/{tryghost-verification-trigger-5.20.0.tgz → tryghost-verification-trigger-5.22.0.tgz} +0 -0
  52. package/components/tryghost-version-notifications-data-service-5.22.0.tgz +0 -0
  53. package/core/boot.js +2 -0
  54. package/core/built/admin/assets/{chunk.143.d245b085ad1efed4ee76.js → chunk.143.082624e9ff0d5bda7e62.js} +5 -5
  55. package/core/built/admin/assets/{chunk.178.c45f56ea31775e509497.js → chunk.178.890cc3faaddd738b7f6d.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-625810fcb6c7ad92f79f78dea13a5ad7.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/importer/importers/data/products.js +47 -0
  76. package/core/server/data/migrations/versions/5.21/2022-10-24-07-23-disable-feedback-enabled.js +20 -0
  77. package/core/server/data/migrations/versions/5.21/2022-10-25-12-05-backfill-missed-products-columns.js +35 -0
  78. package/core/server/data/migrations/versions/5.21/2022-10-26-04-49-add-batch-id-members-created-events.js +7 -0
  79. package/core/server/data/migrations/versions/5.21/2022-10-26-04-49-add-batch-id-subscription-created-events.js +7 -0
  80. package/core/server/data/migrations/versions/5.21/2022-10-26-04-50-member-subscription-created-batch-id.js +72 -0
  81. package/core/server/data/migrations/versions/5.21/2022-10-26-09-32-add-feedback-enabled-column-to-emails.js +7 -0
  82. package/core/server/data/migrations/versions/5.21/2022-10-27-09-50-add-member-track-source-setting.js +8 -0
  83. package/core/server/data/migrations/versions/5.22/2022-10-31-12-03-backfill-new-product-columns.js +35 -0
  84. package/core/server/data/schema/default-settings/default-settings.json +8 -0
  85. package/core/server/data/schema/fixtures/fixture-manager.js +16 -14
  86. package/core/server/data/schema/schema.js +5 -2
  87. package/core/server/models/base/plugins/crud.js +12 -0
  88. package/core/server/models/email.js +1 -0
  89. package/core/server/models/member-click-event.js +13 -0
  90. package/core/server/models/member-created-event.js +23 -0
  91. package/core/server/models/member-paid-subscription-event.js +4 -0
  92. package/core/server/models/member.js +6 -0
  93. package/core/server/models/post.js +1 -1
  94. package/core/server/models/subscription-created-event.js +7 -0
  95. package/core/server/services/mega/feedback-buttons.js +87 -16
  96. package/core/server/services/mega/mega.js +1 -0
  97. package/core/server/services/mega/template.js +3 -0
  98. package/core/server/services/member-attribution/index.js +3 -1
  99. package/core/server/services/members/api.js +2 -0
  100. package/core/server/services/members/service.js +8 -1
  101. package/core/server/services/newsletters/index.js +3 -1
  102. package/core/server/services/newsletters/service.js +11 -1
  103. package/core/server/services/tiers/TierRepository.js +116 -0
  104. package/core/server/services/tiers/index.js +1 -0
  105. package/core/server/services/tiers/service.js +32 -0
  106. package/core/shared/config/defaults.json +1 -1
  107. package/core/shared/labs.js +6 -7
  108. package/ghost.js +1 -0
  109. package/package.json +115 -112
  110. package/yarn.lock +1170 -1097
  111. package/components/tryghost-audience-feedback-5.20.0.tgz +0 -0
  112. package/components/tryghost-bootstrap-socket-5.20.0.tgz +0 -0
  113. package/components/tryghost-custom-theme-settings-service-5.20.0.tgz +0 -0
  114. package/components/tryghost-domain-events-5.20.0.tgz +0 -0
  115. package/components/tryghost-email-analytics-provider-mailgun-5.20.0.tgz +0 -0
  116. package/components/tryghost-email-analytics-service-5.20.0.tgz +0 -0
  117. package/components/tryghost-extract-api-key-5.20.0.tgz +0 -0
  118. package/components/tryghost-link-redirects-5.20.0.tgz +0 -0
  119. package/components/tryghost-link-replacer-5.20.0.tgz +0 -0
  120. package/components/tryghost-link-tracking-5.20.0.tgz +0 -0
  121. package/components/tryghost-mailgun-client-5.20.0.tgz +0 -0
  122. package/components/tryghost-member-analytics-service-5.20.0.tgz +0 -0
  123. package/components/tryghost-member-attribution-5.20.0.tgz +0 -0
  124. package/components/tryghost-member-events-5.20.0.tgz +0 -0
  125. package/components/tryghost-members-analytics-ingress-5.20.0.tgz +0 -0
  126. package/components/tryghost-members-api-5.20.0.tgz +0 -0
  127. package/components/tryghost-members-csv-5.20.0.tgz +0 -0
  128. package/components/tryghost-members-events-service-5.20.0.tgz +0 -0
  129. package/components/tryghost-members-importer-5.20.0.tgz +0 -0
  130. package/components/tryghost-members-payments-5.20.0.tgz +0 -0
  131. package/components/tryghost-members-ssr-5.20.0.tgz +0 -0
  132. package/components/tryghost-members-stripe-service-5.20.0.tgz +0 -0
  133. package/components/tryghost-minifier-5.20.0.tgz +0 -0
  134. package/components/tryghost-mw-error-handler-5.20.0.tgz +0 -0
  135. package/components/tryghost-mw-update-user-last-seen-5.20.0.tgz +0 -0
  136. package/components/tryghost-mw-vhost-5.20.0.tgz +0 -0
  137. package/components/tryghost-package-json-5.20.0.tgz +0 -0
  138. package/components/tryghost-referrers-5.20.0.tgz +0 -0
  139. package/components/tryghost-settings-path-manager-5.20.0.tgz +0 -0
  140. package/components/tryghost-tiers-5.20.0.tgz +0 -0
  141. package/components/tryghost-update-check-service-5.20.0.tgz +0 -0
  142. package/components/tryghost-version-notifications-data-service-5.20.0.tgz +0 -0
  143. package/core/built/admin/assets/ghost-dark-363185f15c782b4b8394c5db23984e7f.css +0 -1
  144. package/core/built/admin/assets/ghost-fd0480352bf27e013b2b00a1bf9ffe84.css +0 -1
@@ -0,0 +1,35 @@
1
+ const logging = require('@tryghost/logging');
2
+ const {createTransactionalMigration} = require('../../utils');
3
+
4
+ module.exports = createTransactionalMigration(
5
+ async function up(knex) {
6
+ logging.info(`Fixing currency/monthly_price/yearly_price values for default paid tiers`);
7
+
8
+ const currencyUpdated = await knex('products')
9
+ .update('currency', 'usd')
10
+ .where({
11
+ currency: null,
12
+ type: 'paid'
13
+ });
14
+ logging.info(`Updated ${currencyUpdated} tier(s) where currency=null, type=paid to currency=USD`);
15
+
16
+ const monthlyPriceUpdated = await knex('products')
17
+ .update('monthly_price', 500)
18
+ .where({
19
+ monthly_price: null,
20
+ type: 'paid'
21
+ });
22
+ logging.info(`Updated ${monthlyPriceUpdated} tier(s) where monthly_price=null, type=paid to monthly_price=500`);
23
+
24
+ const yearlyPriceUpdated = await knex('products')
25
+ .update('yearly_price', 5000)
26
+ .where({
27
+ yearly_price: null,
28
+ type: 'paid'
29
+ });
30
+ logging.info(`Updated ${yearlyPriceUpdated} tier(s) where yearly_price=null, type=paid to yearly_price=5000`);
31
+ },
32
+ async function down(/* knex */) {
33
+ // no-op: we don't want to revert to bad data
34
+ }
35
+ );
@@ -0,0 +1,7 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('members_created_events', 'batch_id', {
4
+ type: 'string',
5
+ maxlength: 24,
6
+ nullable: true
7
+ });
@@ -0,0 +1,7 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('members_subscription_created_events', 'batch_id', {
4
+ type: 'string',
5
+ maxlength: 24,
6
+ nullable: true
7
+ });
@@ -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
+ });
@@ -0,0 +1,35 @@
1
+ const logging = require('@tryghost/logging');
2
+
3
+ const {createTransactionalMigration} = require('../../utils');
4
+
5
+ module.exports = createTransactionalMigration(
6
+ async function up(knex) {
7
+ const rows = await knex('products as t') // eslint-disable-line no-restricted-syntax
8
+ .select(
9
+ 't.id as id',
10
+ 'mp.amount as monthly_price',
11
+ 'yp.amount as yearly_price',
12
+ knex.raw('coalesce(yp.currency, mp.currency) as currency')
13
+ )
14
+ .leftJoin('stripe_prices AS mp', 't.monthly_price_id', 'mp.id')
15
+ .leftJoin('stripe_prices AS yp', 't.yearly_price_id', 'yp.id')
16
+ .where({
17
+ 't.type': 'paid',
18
+ 't.currency': null
19
+ });
20
+
21
+ if (!rows.length) {
22
+ logging.info('Did not find any active paid Tiers');
23
+ return;
24
+ } else {
25
+ logging.info(`Updating ${rows.length} Tiers with price and currency information`);
26
+ }
27
+
28
+ for (const row of rows) { // eslint-disable-line no-restricted-syntax
29
+ await knex('products').update(row).where('id', row.id);
30
+ }
31
+ },
32
+ async function down() {
33
+ // no-op: we don't want to reintroduce the missing data
34
+ }
35
+ );
@@ -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,