ghost 4.42.1 → 4.43.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 (111) hide show
  1. package/content/themes/casper/package.json +2 -3
  2. package/content/themes/casper/partials/post-card.hbs +1 -1
  3. package/core/built/assets/{ghost-dark-a93afb20027060d760ac6d78f115a76f.css → ghost-dark-1933079797e24ccb8839657020830be5.css} +1 -1
  4. package/core/built/assets/{ghost.min-20096eef632760c3a2906e243adbd24b.js → ghost.min-2a278873d60d6a13a4c05a396e5bed5e.js} +336 -278
  5. package/core/built/assets/{ghost.min-ce35ef1b76d9a943ab912c076773b132.css → ghost.min-38f3c38c0c6a1864f57079b068a0b0ce.css} +1 -1
  6. package/core/frontend/apps/amp/lib/helpers/amp_analytics.js +1 -1
  7. package/core/frontend/apps/amp/lib/helpers/amp_components.js +1 -1
  8. package/core/frontend/apps/amp/lib/helpers/amp_content.js +1 -1
  9. package/core/frontend/apps/amp/lib/helpers/amp_style.js +1 -1
  10. package/core/frontend/apps/amp/lib/router.js +6 -5
  11. package/core/frontend/apps/private-blogging/lib/helpers/input_password.js +1 -1
  12. package/core/frontend/apps/private-blogging/lib/router.js +2 -2
  13. package/core/frontend/helpers/asset.js +1 -1
  14. package/core/frontend/helpers/author.js +1 -1
  15. package/core/frontend/helpers/authors.js +1 -1
  16. package/core/frontend/helpers/body_class.js +1 -1
  17. package/core/frontend/helpers/cancel_link.js +1 -1
  18. package/core/frontend/helpers/concat.js +1 -1
  19. package/core/frontend/helpers/content.js +1 -1
  20. package/core/frontend/helpers/date.js +1 -1
  21. package/core/frontend/helpers/encode.js +1 -1
  22. package/core/frontend/helpers/excerpt.js +1 -1
  23. package/core/frontend/helpers/facebook_url.js +1 -1
  24. package/core/frontend/helpers/foreach.js +2 -2
  25. package/core/frontend/helpers/get.js +1 -1
  26. package/core/frontend/helpers/ghost_foot.js +1 -1
  27. package/core/frontend/helpers/ghost_head.js +1 -1
  28. package/core/frontend/helpers/lang.js +1 -1
  29. package/core/frontend/helpers/link.js +1 -1
  30. package/core/frontend/helpers/link_class.js +1 -1
  31. package/core/frontend/helpers/match.js +1 -1
  32. package/core/frontend/helpers/navigation.js +1 -1
  33. package/core/frontend/helpers/pagination.js +1 -1
  34. package/core/frontend/helpers/plural.js +1 -1
  35. package/core/frontend/helpers/post_class.js +1 -1
  36. package/core/frontend/helpers/prev_post.js +6 -5
  37. package/core/frontend/helpers/products.js +1 -1
  38. package/core/frontend/helpers/reading_time.js +2 -2
  39. package/core/frontend/helpers/t.js +1 -1
  40. package/core/frontend/helpers/tags.js +1 -1
  41. package/core/frontend/helpers/tiers.js +1 -1
  42. package/core/frontend/helpers/title.js +1 -1
  43. package/core/frontend/helpers/twitter_url.js +1 -1
  44. package/core/frontend/helpers/url.js +1 -1
  45. package/core/frontend/meta/url.js +4 -4
  46. package/core/{server/data/schema → frontend/services/data}/checks.js +4 -4
  47. package/core/frontend/services/{routing/helpers → data}/entry-lookup.js +3 -3
  48. package/core/frontend/services/{routing/helpers → data}/fetch-data.js +3 -3
  49. package/core/frontend/services/data/index.js +5 -0
  50. package/core/frontend/services/{rendering.js → handlebars.js} +2 -1
  51. package/core/frontend/services/helpers/handlebars.js +1 -1
  52. package/core/frontend/services/proxy.js +2 -4
  53. package/core/frontend/services/{routing/helpers → rendering}/context.js +0 -0
  54. package/core/frontend/services/{routing/helpers → rendering}/error.js +0 -0
  55. package/core/frontend/services/{routing/helpers → rendering}/format-response.js +1 -1
  56. package/core/frontend/services/{routing/helpers → rendering}/index.js +0 -8
  57. package/core/frontend/services/{routing/helpers → rendering}/render-entries.js +1 -1
  58. package/core/frontend/services/{routing/helpers → rendering}/render-entry.js +1 -1
  59. package/core/frontend/services/{routing/helpers → rendering}/renderer.js +1 -1
  60. package/core/frontend/services/{routing/helpers → rendering}/secure.js +0 -0
  61. package/core/frontend/services/{routing/helpers → rendering}/templates.js +2 -2
  62. package/core/frontend/services/routing/CollectionRouter.js +1 -1
  63. package/core/frontend/services/routing/controllers/channel.js +9 -9
  64. package/core/frontend/services/routing/controllers/collection.js +9 -9
  65. package/core/frontend/services/routing/controllers/email-post.js +5 -6
  66. package/core/frontend/services/routing/controllers/entry.js +6 -6
  67. package/core/frontend/services/routing/controllers/preview.js +5 -6
  68. package/core/frontend/services/routing/controllers/rss.js +4 -3
  69. package/core/frontend/services/routing/controllers/static.js +5 -5
  70. package/core/frontend/services/routing/controllers/unsubscribe.js +2 -2
  71. package/core/frontend/services/routing/index.js +0 -4
  72. package/core/frontend/web/middleware/error-handler.js +2 -2
  73. package/core/server/api/canary/stats.js +9 -0
  74. package/core/server/api/canary/utils/serializers/output/members.js +5 -0
  75. package/core/server/api/canary/utils/validators/input/index.js +6 -0
  76. package/core/server/api/shared/http.js +52 -51
  77. package/core/server/data/exporter/table-lists.js +1 -0
  78. package/core/server/data/migrations/utils.js +33 -1
  79. package/core/server/data/migrations/versions/4.42/2022-03-21-17-17-add.js +5 -0
  80. package/core/server/data/migrations/versions/4.43/2022-03-28-19-26-recreate-newsletter-table.js +29 -0
  81. package/core/server/data/migrations/versions/4.43/2022-03-29-14-45-add-members-newsletters-table.js +7 -0
  82. package/core/server/data/migrations/versions/4.43/2022-04-01-10-13-add-post-newsletter-relation.js +108 -0
  83. package/core/server/data/migrations/versions/4.43/2022-04-06-09-47-add-type-column-to-paid-subscription-events.js +7 -0
  84. package/core/server/data/migrations/versions/4.43/2022-04-06-14-56-add-email-newsletter-relation.js +8 -0
  85. package/core/server/data/migrations/versions/4.43/2022-04-08-10-45-add-subscription-id-to-mrr-events.js +7 -0
  86. package/core/server/data/schema/commands.js +6 -1
  87. package/core/server/data/schema/index.js +0 -1
  88. package/core/server/data/schema/schema.js +34 -16
  89. package/core/server/models/base/bookshelf.js +1 -1
  90. package/core/server/models/base/plugins/crud.js +8 -0
  91. package/core/server/models/member.js +18 -1
  92. package/core/server/models/newsletter.js +35 -1
  93. package/core/server/models/post.js +4 -1
  94. package/core/server/services/auth/setup.js +4 -1
  95. package/core/server/services/members/api.js +3 -1
  96. package/core/server/services/members/middleware.js +13 -3
  97. package/core/server/services/members/utils.js +13 -1
  98. package/core/server/services/newsletters/index.js +10 -0
  99. package/core/server/services/newsletters/service.js +24 -0
  100. package/core/server/services/slack.js +11 -3
  101. package/core/server/services/stats/lib/members-stats-service.js +30 -34
  102. package/core/server/services/stats/lib/mrr-stats-service.js +154 -0
  103. package/core/server/services/stats/service.js +3 -1
  104. package/core/server/services/stripe/service.js +1 -0
  105. package/core/server/web/admin/views/default-prod.html +3 -3
  106. package/core/server/web/admin/views/default.html +3 -3
  107. package/core/server/web/api/canary/admin/routes.js +1 -0
  108. package/core/shared/config/defaults.json +2 -2
  109. package/package.json +33 -33
  110. package/yarn.lock +352 -277
  111. package/content/themes/casper/assets/css/csscomb.json +0 -240
@@ -0,0 +1,108 @@
1
+ const logging = require('@tryghost/logging');
2
+ const DatabaseInfo = require('@tryghost/database-info');
3
+ const commands = require('../../../schema/commands');
4
+ const {createTransactionalMigration} = require('../../utils');
5
+
6
+ const table = 'posts';
7
+ const column = 'newsletter_id';
8
+ const targetTable = 'newsletters';
9
+ const targetColumn = 'id';
10
+
11
+ const columnDefinition = {
12
+ type: 'string',
13
+ maxlength: 24,
14
+ nullable: true,
15
+ references: `${targetTable}.${targetColumn}`
16
+ };
17
+
18
+ /**
19
+ * This migration is adding a new column `newsletter_id` to the table posts
20
+ * that is a foreign key to `newsletters.id`.
21
+ *
22
+ * It isn't using the existing utils because of a performance issue. In MySQL,
23
+ * adding a new row without `algorithm=copy` uses the INPLACE algorithm which
24
+ * was too slow on big `posts` tables (~3 minutes for 10k posts). Switching to
25
+ * the COPY algorithm fixed the issue (~3 seconds for 10k posts).
26
+ */
27
+ module.exports = createTransactionalMigration(
28
+ async function up(knex) {
29
+ const hasColumn = await knex.schema.hasColumn(table, column);
30
+
31
+ if (hasColumn) {
32
+ logging.info(`Adding ${table}.${column} column - skipping as table is correct`);
33
+ return;
34
+ }
35
+
36
+ logging.info(`Adding ${table}.${column} column`);
37
+
38
+ // Use the default flow for SQLite because .toSQL() is tricky with SQLite
39
+ if (DatabaseInfo.isSQLite(knex)) {
40
+ await commands.addColumn(table, column, knex, columnDefinition);
41
+ return;
42
+ }
43
+
44
+ // Add the column
45
+
46
+ let sql = knex.schema.table(table, function (t) {
47
+ t.string(column, 24);
48
+ }).toSQL()[0].sql;
49
+
50
+ if (DatabaseInfo.isMySQL(knex)) {
51
+ // Guard against an ending semicolon
52
+ sql = sql.replace(/;\s*$/, '') + ', algorithm=copy';
53
+ }
54
+
55
+ await knex.raw(sql);
56
+
57
+ // Add the foreign key constraint
58
+
59
+ await commands.addForeign({
60
+ fromTable: table,
61
+ fromColumn: column,
62
+ toTable: targetTable,
63
+ toColumn: targetColumn,
64
+ cascadeDelete: false,
65
+ transaction: knex
66
+ });
67
+ },
68
+ async function down(knex) {
69
+ const hasColumn = await knex.schema.hasColumn(table, column);
70
+
71
+ if (!hasColumn) {
72
+ logging.info(`Removing ${table}.${column} column - skipping as table is correct`);
73
+ return;
74
+ }
75
+
76
+ logging.info(`Removing ${table}.${column} column`);
77
+
78
+ // Use the default flow for SQLite because .toSQL() is tricky with SQLite
79
+ if (DatabaseInfo.isSQLite(knex)) {
80
+ await commands.dropColumn(table, column, knex, columnDefinition);
81
+ return;
82
+ }
83
+
84
+ // Drop the foreign key constraint
85
+
86
+ await commands.dropForeign({
87
+ fromTable: table,
88
+ fromColumn: column,
89
+ toTable: targetTable,
90
+ toColumn: targetColumn,
91
+ transaction: knex
92
+ });
93
+
94
+ // Drop the column
95
+
96
+ let sql = knex.schema.table(table, function (t) {
97
+ t.dropColumn(column);
98
+ }).toSQL()[0].sql;
99
+
100
+ if (DatabaseInfo.isMySQL(knex)) {
101
+ // Guard against an ending semicolon
102
+ sql = sql.replace(/;\s*$/, '') + ', algorithm=copy';
103
+ }
104
+
105
+ await knex.raw(sql);
106
+ }
107
+ );
108
+
@@ -0,0 +1,7 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('members_paid_subscription_events', 'type', {
4
+ type: 'string',
5
+ maxlength: '50',
6
+ nullable: true
7
+ });
@@ -0,0 +1,8 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('emails', 'newsletter_id', {
4
+ type: 'string',
5
+ maxlength: 24,
6
+ nullable: true,
7
+ references: 'newsletters.id'
8
+ });
@@ -0,0 +1,7 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('members_paid_subscription_events', 'subscription_id', {
4
+ type: 'string',
5
+ maxlength: 24,
6
+ nullable: true
7
+ });
@@ -65,7 +65,12 @@ function addColumn(tableName, column, transaction = db.knex, columnSpec) {
65
65
  });
66
66
  }
67
67
 
68
- function dropColumn(tableName, column, transaction = db.knex) {
68
+ async function dropColumn(tableName, column, transaction = db.knex, columnSpec = {}) {
69
+ if (Object.prototype.hasOwnProperty.call(columnSpec, 'references')) {
70
+ const [toTable, toColumn] = columnSpec.references.split('.');
71
+ await dropForeign({fromTable: tableName, fromColumn: column, toTable, toColumn, transaction});
72
+ }
73
+
69
74
  return transaction.schema.table(tableName, function (table) {
70
75
  table.dropColumn(column);
71
76
  });
@@ -1,5 +1,4 @@
1
1
  module.exports.tables = require('./schema');
2
- module.exports.checks = require('./checks');
3
2
  module.exports.commands = require('./commands');
4
3
  module.exports.defaultSettings = require('./default-settings');
5
4
  module.exports.validate = require('./validator');
@@ -8,6 +8,33 @@
8
8
  * Long text = length 1,000,000,000
9
9
  */
10
10
  module.exports = {
11
+ newsletters: {
12
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
13
+ name: {type: 'string', maxlength: 191, nullable: false, unique: true},
14
+ description: {type: 'string', maxlength: 2000, nullable: true},
15
+ slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
16
+ sender_name: {type: 'string', maxlength: 191, nullable: false},
17
+ sender_email: {type: 'string', maxlength: 191, nullable: true},
18
+ sender_reply_to: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'newsletter', validations: {isIn: ['newsletter', 'support']}},
19
+ status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'active'},
20
+ visibility: {
21
+ type: 'string',
22
+ maxlength: 50,
23
+ nullable: false,
24
+ defaultTo: 'members'
25
+ },
26
+ subscribe_on_signup: {type: 'bool', nullable: false, defaultTo: true},
27
+ sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0},
28
+ header_image: {type: 'string', maxlength: 2000, nullable: true},
29
+ show_header_icon: {type: 'bool', nullable: false, defaultTo: true},
30
+ show_header_title: {type: 'bool', nullable: false, defaultTo: true},
31
+ title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: ['serif', 'sans_serif']}},
32
+ title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: ['center', 'left']}},
33
+ show_feature_image: {type: 'bool', nullable: false, defaultTo: true},
34
+ body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: ['serif', 'sans_serif']}},
35
+ footer_content: {type: 'text', maxlength: 1000000000, nullable: true},
36
+ show_badge: {type: 'bool', nullable: false, defaultTo: true}
37
+ },
11
38
  posts: {
12
39
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
13
40
  uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}},
@@ -56,6 +83,7 @@ module.exports = {
56
83
  codeinjection_foot: {type: 'text', maxlength: 65535, nullable: true},
57
84
  custom_template: {type: 'string', maxlength: 100, nullable: true},
58
85
  canonical_url: {type: 'text', maxlength: 2000, nullable: true},
86
+ newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'},
59
87
  '@@UNIQUE_CONSTRAINTS@@': [
60
88
  ['slug', 'type']
61
89
  ]
@@ -492,7 +520,9 @@ module.exports = {
492
520
  },
493
521
  members_paid_subscription_events: {
494
522
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
523
+ type: {type: 'string', maxlength: 50, nullable: true},
495
524
  member_id: {type: 'string', maxlength: 24, nullable: false, references: 'members.id', cascadeDelete: true},
525
+ subscription_id: {type: 'string', maxlength: 24, nullable: true},
496
526
  from_plan: {type: 'string', maxlength: 255, nullable: true},
497
527
  to_plan: {type: 'string', maxlength: 255, nullable: true},
498
528
  currency: {type: 'string', maxLength: 3, nullable: false},
@@ -630,6 +660,7 @@ module.exports = {
630
660
  plaintext: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
631
661
  track_opens: {type: 'bool', nullable: false, defaultTo: false},
632
662
  submitted_at: {type: 'dateTime', nullable: false},
663
+ newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'},
633
664
  created_at: {type: 'dateTime', nullable: false},
634
665
  created_by: {type: 'string', maxlength: 24, nullable: false},
635
666
  updated_at: {type: 'dateTime', nullable: true},
@@ -712,22 +743,9 @@ module.exports = {
712
743
  },
713
744
  value: {type: 'text', maxlength: 65535, nullable: true}
714
745
  },
715
- newsletters: {
746
+ members_newsletters: {
716
747
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
717
- name: {type: 'string', maxlength: 191, nullable: false},
718
- description: {type: 'string', maxlength: 2000, nullable: true},
719
- sender_name: {type: 'string', maxlength: 191, nullable: false},
720
- sender_email: {type: 'string', maxlength: 191, nullable: false, validations: {isEmail: true}},
721
- sender_reply_to: {type: 'string', maxlength: 191, nullable: false, validations: {isEmail: true}},
722
- default: {type: 'bool', nullable: false, defaultTo: false},
723
- status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'active'},
724
- recipient_filter: {
725
- type: 'text',
726
- maxlength: 1000000000,
727
- nullable: false,
728
- defaultTo: ''
729
- },
730
- subscribe_on_signup: {type: 'bool', nullable: false, defaultTo: false},
731
- sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0}
748
+ member_id: {type: 'string', maxlength: 24, nullable: false, references: 'members.id', cascadeDelete: true},
749
+ newsletter_id: {type: 'string', maxlength: 24, nullable: false, references: 'newsletters.id', cascadeDelete: true}
732
750
  }
733
751
  };
@@ -78,7 +78,7 @@ ghostBookshelf.plugin('bookshelf-relations', {
78
78
  };
79
79
 
80
80
  // CASE: disable after hook for specific relations
81
- if (['permissions_roles'].indexOf(existing.relatedData.joinTableName) !== -1) {
81
+ if (['permissions_roles', 'members_newsletters'].indexOf(existing.relatedData.joinTableName) !== -1) {
82
82
  return Promise.resolve();
83
83
  }
84
84
 
@@ -137,6 +137,10 @@ module.exports = function (Bookshelf) {
137
137
  options.columns = _.intersection(options.columns, this.prototype.permittedAttributes());
138
138
  }
139
139
 
140
+ if (options.transacting && options.forUpdate) {
141
+ options.lock = 'forUpdate';
142
+ }
143
+
140
144
  return model.fetch(options)
141
145
  .catch((err) => {
142
146
  // CASE: SQL syntax is incorrect
@@ -179,6 +183,10 @@ module.exports = function (Bookshelf) {
179
183
  model.hasTimestamps = false;
180
184
  }
181
185
 
186
+ if (options.transacting) {
187
+ options.lock = 'forUpdate';
188
+ }
189
+
182
190
  const object = await model.fetch(options);
183
191
  if (object) {
184
192
  options.method = 'update';
@@ -49,6 +49,13 @@ const Member = ghostBookshelf.Model.extend({
49
49
  joinFrom: 'member_id',
50
50
  joinTo: 'product_id'
51
51
  },
52
+ newsletters: {
53
+ tableName: 'newsletters',
54
+ type: 'manyToMany',
55
+ joinTable: 'members_newsletters',
56
+ joinFrom: 'member_id',
57
+ joinTo: 'newsletter_id'
58
+ },
52
59
  subscriptions: {
53
60
  tableName: 'members_stripe_customers_subscriptions',
54
61
  tableNameAs: 'subscriptions',
@@ -61,7 +68,7 @@ const Member = ghostBookshelf.Model.extend({
61
68
  };
62
69
  },
63
70
 
64
- relationships: ['products', 'labels', 'stripeCustomers', 'email_recipients'],
71
+ relationships: ['products', 'labels', 'stripeCustomers', 'email_recipients', 'newsletters'],
65
72
 
66
73
  // do not delete email_recipients records when a member is destroyed. Recipient
67
74
  // records are used for analytics and historical records
@@ -73,6 +80,7 @@ const Member = ghostBookshelf.Model.extend({
73
80
 
74
81
  relationshipBelongsTo: {
75
82
  products: 'products',
83
+ newsletters: 'newsletters',
76
84
  labels: 'labels',
77
85
  stripeCustomers: 'members_stripe_customers',
78
86
  email_recipients: 'email_recipients'
@@ -94,6 +102,15 @@ const Member = ghostBookshelf.Model.extend({
94
102
  });
95
103
  },
96
104
 
105
+ newsletters() {
106
+ return this.belongsToMany('Newsletter', 'members_newsletters', 'member_id', 'newsletter_id')
107
+ .query((qb) => {
108
+ // avoids bookshelf adding a `DISTINCT` to the query
109
+ // we know the result set will already be unique and DISTINCT hurts query performance
110
+ qb.columns('newsletters.*');
111
+ });
112
+ },
113
+
97
114
  offerRedemptions() {
98
115
  return this.hasMany('OfferRedemption', 'member_id', 'id')
99
116
  .query('orderBy', 'created_at', 'DESC');
@@ -1,7 +1,41 @@
1
1
  const ghostBookshelf = require('./base');
2
2
 
3
3
  const Newsletter = ghostBookshelf.Model.extend({
4
- tableName: 'newsletters'
4
+ tableName: 'newsletters',
5
+
6
+ defaults: {
7
+ sender_reply_to: 'newsletter',
8
+ status: 'active',
9
+ visibility: 'members',
10
+ subscribe_on_signup: true,
11
+ sort_order: 0,
12
+ title_font_category: 'sans_serif',
13
+ title_alignment: 'center',
14
+ show_feature_image: true,
15
+ body_font_category: 'sans_serif',
16
+ show_badge: true
17
+ },
18
+
19
+ async onSaving(model, _attr, options) {
20
+ ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);
21
+
22
+ if (model.get('name')) {
23
+ model.set('name', model.get('name').trim());
24
+ }
25
+
26
+ if (model.hasChanged('slug') || !model.get('slug')) {
27
+ const slug = model.get('slug') || model.get('name');
28
+
29
+ if (slug) {
30
+ const cleanSlug = await ghostBookshelf.Model.generateSlug(Newsletter, slug, {
31
+ transacting: options.transacting
32
+ });
33
+
34
+ model.set({slug: cleanSlug});
35
+ }
36
+ }
37
+ }
38
+
5
39
  });
6
40
 
7
41
  module.exports = {
@@ -557,7 +557,7 @@ Post = ghostBookshelf.Model.extend({
557
557
  if (!tag.id && !tag.tag_id && tag.slug) {
558
558
  // Clean up the provided slugs before we do any matching with existing tags
559
559
  tag.slug = await ghostBookshelf.Model.generateSlug(
560
- Tag,
560
+ Tag,
561
561
  tag.slug,
562
562
  {skipDuplicateChecks: true}
563
563
  );
@@ -878,6 +878,9 @@ Post = ghostBookshelf.Model.extend({
878
878
  // CASE: never expose the revisions
879
879
  delete attrs.mobiledoc_revisions;
880
880
 
881
+ // CASE: hide the newsletter_id for now
882
+ delete attrs.newsletter_id;
883
+
881
884
  // If the current column settings allow it...
882
885
  if (!options.columns || (options.columns && options.columns.indexOf('primary_tag') > -1)) {
883
886
  // ... attach a computed property of primary_tag which is the first tag if it is public, else null
@@ -142,7 +142,10 @@ async function doFixtures(data) {
142
142
  mobiledoc = mobiledoc.replace(/{{date}}/, date);
143
143
 
144
144
  const post = await models.Post.findOne({slug: key});
145
- await models.Post.edit({mobiledoc}, {id: post.id});
145
+
146
+ if (post) {
147
+ await models.Post.edit({mobiledoc}, {id: post.id});
148
+ }
146
149
  });
147
150
 
148
151
  return data;
@@ -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 getNewslettersServiceInstance = require('../newsletters');
16
17
 
17
18
  const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
18
19
 
@@ -195,7 +196,8 @@ function createApiInstance(config) {
195
196
  },
196
197
  stripeAPIService: stripeService.api,
197
198
  offersAPI: offersService.api,
198
- labsService: labsService
199
+ labsService: labsService,
200
+ newslettersService: getNewslettersServiceInstance({NewsletterModel: models.Newsletter})
199
201
  });
200
202
 
201
203
  return membersApiInstance;
@@ -70,12 +70,12 @@ const getOfferData = async function (req, res) {
70
70
 
71
71
  const updateMemberData = async function (req, res) {
72
72
  try {
73
- const data = _.pick(req.body, 'name', 'subscribed');
73
+ const data = _.pick(req.body, 'name', 'subscribed', 'newsletters');
74
74
  const member = await membersService.ssr.getMemberDataFromSession(req, res);
75
75
  if (member) {
76
76
  const options = {
77
77
  id: member.id,
78
- withRelated: ['stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice']
78
+ withRelated: ['stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'newsletters']
79
79
  };
80
80
  const updatedMember = await membersService.api.members.update(data, options);
81
81
 
@@ -133,6 +133,11 @@ const getPortalProductPrices = async function () {
133
133
  };
134
134
  };
135
135
 
136
+ const getSiteNewsletters = async function () {
137
+ const newsletters = await models.Newsletter.findAll();
138
+ return newsletters.toJSON();
139
+ };
140
+
136
141
  const getMemberSiteData = async function (req, res) {
137
142
  const isStripeConfigured = membersService.config.isStripeConnected();
138
143
  const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
@@ -144,7 +149,7 @@ const getMemberSiteData = async function (req, res) {
144
149
  }
145
150
  const {products = [], prices = []} = await getPortalProductPrices() || {};
146
151
  const portalVersion = config.get('portal:version');
147
-
152
+ const newsletters = await getSiteNewsletters();
148
153
  const response = {
149
154
  title: settingsCache.get('title'),
150
155
  description: settingsCache.get('description'),
@@ -170,6 +175,11 @@ const getMemberSiteData = async function (req, res) {
170
175
  prices,
171
176
  products
172
177
  };
178
+
179
+ if (labsService.isSet('multipleNewsletters')) {
180
+ response.newsletters = newsletters;
181
+ }
182
+
173
183
  if (labsService.isSet('multipleProducts')) {
174
184
  response.portal_products = settingsCache.get('portal_products');
175
185
  }
@@ -1,8 +1,16 @@
1
+ const labsService = require('../../../shared/labs');
2
+
3
+ function formatNewsletterResponse(newsletters) {
4
+ return newsletters.map(({id, name, description, sort_order: sortOrder}) => {
5
+ return {id, name, description, sort_order: sortOrder};
6
+ });
7
+ }
8
+
1
9
  module.exports.formattedMemberResponse = function formattedMemberResponse(member) {
2
10
  if (!member) {
3
11
  return null;
4
12
  }
5
- return {
13
+ const data = {
6
14
  uuid: member.uuid,
7
15
  email: member.email,
8
16
  name: member.name,
@@ -12,4 +20,8 @@ module.exports.formattedMemberResponse = function formattedMemberResponse(member
12
20
  subscriptions: member.subscriptions || [],
13
21
  paid: member.status !== 'free'
14
22
  };
23
+ if (member.newsletters && labsService.isSet('multipleNewsletters')) {
24
+ data.newsletters = formatNewsletterResponse(member.newsletters);
25
+ }
26
+ return data;
15
27
  };
@@ -0,0 +1,10 @@
1
+ const NewslettersService = require('./service.js');
2
+
3
+ /**
4
+ * @returns {NewslettersService} instance of the NewslettersService
5
+ */
6
+ const getNewslettersServiceInstance = ({NewsletterModel}) => {
7
+ return new NewslettersService({NewsletterModel});
8
+ };
9
+
10
+ module.exports = getNewslettersServiceInstance;
@@ -0,0 +1,24 @@
1
+ class NewslettersService {
2
+ /**
3
+ *
4
+ * @param {Object} options
5
+ * @param {Object} options.NewsletterModel
6
+ */
7
+ constructor({NewsletterModel}) {
8
+ this.NewsletterModel = NewsletterModel;
9
+ }
10
+
11
+ /**
12
+ *
13
+ * @param {Object} options browse options
14
+ * @returns
15
+ */
16
+ async browse(options) {
17
+ let newsletters = await this.NewsletterModel.findAll(options);
18
+
19
+ return newsletters.toJSON();
20
+ }
21
+ }
22
+
23
+ module.exports = NewslettersService;
24
+
@@ -6,7 +6,6 @@ const {blogIcon} = require('../lib/image');
6
6
  const urlUtils = require('../../shared/url-utils');
7
7
  const urlService = require('./url');
8
8
  const settingsCache = require('../../shared/settings-cache');
9
- const schema = require('../data/schema').checks;
10
9
  const moment = require('moment');
11
10
 
12
11
  // Used to receive post.published model event, but also the slack.test event from the API which iirc this was done to avoid circular deps a long time ago
@@ -37,6 +36,15 @@ function getSlackSettings() {
37
36
  };
38
37
  }
39
38
 
39
+ /**
40
+ * @TODO: change this function to check for the properties we depend on
41
+ * @param {Object} data
42
+ * @returns {boolean}
43
+ */
44
+ function hasPostProperties(data) {
45
+ return Object.prototype.hasOwnProperty.call(data, 'html') && Object.prototype.hasOwnProperty.call(data, 'title') && Object.prototype.hasOwnProperty.call(data, 'slug');
46
+ }
47
+
40
48
  function ping(post) {
41
49
  let message;
42
50
  let title;
@@ -47,7 +55,7 @@ function ping(post) {
47
55
  let blogTitle = settingsCache.get('title');
48
56
 
49
57
  // If this is a post, we want to send the link of the post
50
- if (schema.isPost(post)) {
58
+ if (hasPostProperties(post)) {
51
59
  message = urlService.getUrlByResourceId(post.id, {absolute: true});
52
60
  title = post.title ? post.title : null;
53
61
  author = post.authors ? post.authors[0] : null;
@@ -79,7 +87,7 @@ function ping(post) {
79
87
  return;
80
88
  }
81
89
 
82
- if (schema.isPost(post)) {
90
+ if (hasPostProperties(post)) {
83
91
  slackData = {
84
92
  // We are handling the case of test notification here by checking
85
93
  // if it is a post or a test message to check webhook working.