ghost 4.44.0 → 4.46.1

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 (108) hide show
  1. package/Gruntfile.js +1 -1
  2. package/core/boot.js +2 -0
  3. package/core/built/assets/{chunk.3.6e2ed2d00856e12bd81a.js → chunk.3.52b444495dfcf50afb0b.js} +20 -20
  4. package/core/built/assets/ghost-dark-155e039c0d991b7af75dea8cd3846b11.css +1 -0
  5. package/core/built/assets/{ghost.min-1e7dce606e92a03207d15ae7eb3d3c23.js → ghost.min-30e597cb65b62b31a9422ca9c0eb2890.js} +777 -632
  6. package/core/built/assets/ghost.min-bd8cd0185fd5dfc8291502f801e443e6.css +1 -0
  7. package/core/built/assets/icons/clock.svg +1 -1
  8. package/core/built/assets/icons/email-at.svg +1 -0
  9. package/core/built/assets/icons/email-body.svg +1 -0
  10. package/core/built/assets/icons/email-footer.svg +1 -0
  11. package/core/built/assets/icons/email-header.svg +1 -0
  12. package/core/built/assets/icons/email-member.svg +1 -0
  13. package/core/built/assets/icons/email-name.svg +1 -0
  14. package/core/built/assets/icons/member.svg +1 -3
  15. package/core/built/assets/icons/send-email.svg +1 -1
  16. package/core/built/assets/img/abstract-2-2937e2902b64360d0cbe4cec8bd8479b.jpg +0 -0
  17. package/core/built/assets/img/abstract-c52b2f4208e7fd2e7b8abd8b1eec4f7b.jpg +0 -0
  18. package/core/built/assets/img/community-background-3f501ff1d764d0cb81f7c2cbacfc6503.jpg +0 -0
  19. package/core/built/assets/img/community-be8c1dcecfb157f2bfba5cababc8e686.jpg +0 -0
  20. package/core/built/assets/img/newsletter-1-197ae8063dfb2e22278d355198029c9e.jpg +0 -0
  21. package/core/built/assets/img/newsletter-2-5a2c7693ea9380d4282061302c01267a.jpg +0 -0
  22. package/core/built/assets/img/resource-1-722f202795856e4a5596c8a3b7bedc43.jpg +0 -0
  23. package/core/built/assets/{vendor.min-fe2c9b1235b4119b5406b788db2db434.js → vendor.min-97fd438f4772c5ec6bb30ad779b8530e.js} +862 -523
  24. package/core/frontend/apps/amp/lib/helpers/amp_content.js +2 -3
  25. package/core/frontend/apps/amp/lib/views/amp.hbs +5 -3
  26. package/core/frontend/helpers/get.js +1 -1
  27. package/core/frontend/services/routing/controllers/unsubscribe.js +22 -0
  28. package/core/frontend/web/middleware/cors.js +56 -0
  29. package/core/frontend/web/middleware/index.js +1 -0
  30. package/core/frontend/web/middleware/static-theme.js +8 -8
  31. package/core/frontend/web/site.js +1 -48
  32. package/core/server/api/canary/members.js +3 -0
  33. package/core/server/api/canary/newsletters.js +86 -4
  34. package/core/server/api/canary/stats.js +11 -2
  35. package/core/server/api/canary/utils/serializers/input/members.js +22 -0
  36. package/core/server/api/canary/utils/serializers/output/mappers/pages.js +1 -0
  37. package/core/server/api/canary/utils/serializers/output/mappers/posts.js +2 -0
  38. package/core/server/api/canary/utils/serializers/output/members.js +13 -5
  39. package/core/server/api/v2/utils/serializers/output/utils/mapper.js +2 -0
  40. package/core/server/api/v3/utils/serializers/output/utils/mapper.js +3 -0
  41. package/core/server/data/importer/importers/data/settings.js +0 -3
  42. package/core/server/data/migrations/utils.js +40 -0
  43. package/core/server/data/migrations/versions/4.43/2022-03-28-19-26-recreate-newsletter-table.js +1 -1
  44. package/core/server/data/migrations/versions/4.45/2022-04-19-12-23-backfill-subscriptions-offers.js +60 -0
  45. package/core/server/data/migrations/versions/4.45/2022-04-20-11-25-add-newsletter-read-permission.js +9 -0
  46. package/core/server/data/migrations/versions/4.45/2022-04-21-02-55-add-notifications-key-entry-to-settings-table.js +8 -0
  47. package/core/server/data/migrations/versions/4.46/2022-04-13-12-00-add-created-at-newsletters.js +6 -0
  48. package/core/server/data/migrations/versions/4.46/2022-04-13-12-01-add-updated-at-newsletters.js +6 -0
  49. package/core/server/data/migrations/versions/4.46/2022-04-13-12-02-fill-created-at-newsletters.js +19 -0
  50. package/core/server/data/migrations/versions/4.46/2022-04-13-12-03-drop-nullable-created-at-newsletters.js +3 -0
  51. package/core/server/data/migrations/versions/4.46/2022-04-13-12-08-newsletters-show-header-name.js +7 -0
  52. package/core/server/data/migrations/versions/4.46/2022-04-13-12-57-add-uuid-column-to-newsletters.js +8 -0
  53. package/core/server/data/migrations/versions/4.46/2022-04-13-12-58-fill-uuid-for-newsletters.js +19 -0
  54. package/core/server/data/migrations/versions/4.46/2022-04-13-12-59-drop-nullable-uuid-newsletters.js +3 -0
  55. package/core/server/data/migrations/versions/4.46/2022-04-13-13-00-add-default-newsletter.js +92 -0
  56. package/core/server/data/migrations/versions/4.46/2022-04-20-08-39-map-subscribers-to-default-newsletter.js +66 -0
  57. package/core/server/data/migrations/versions/4.46/2022-04-22-07-43-add-newsletter-id-to-subscribe-events.js +9 -0
  58. package/core/server/data/migrations/versions/4.46/2022-04-27-07-59-set-newsletter-id-subscribe-events.js +31 -0
  59. package/core/server/data/schema/commands.js +14 -0
  60. package/core/server/data/schema/default-settings/default-settings.json +4 -0
  61. package/core/server/data/schema/fixtures/fixtures.json +7 -1
  62. package/core/server/data/schema/schema.js +8 -3
  63. package/core/server/models/base/plugins/generate-slug.js +2 -2
  64. package/core/server/models/email.js +4 -0
  65. package/core/server/models/label.js +1 -1
  66. package/core/server/models/member-subscribe-event.js +4 -0
  67. package/core/server/models/member.js +26 -0
  68. package/core/server/models/newsletter.js +97 -14
  69. package/core/server/models/post.js +7 -4
  70. package/core/server/models/role.js +1 -1
  71. package/core/server/models/tag.js +1 -1
  72. package/core/server/models/user.js +1 -1
  73. package/core/server/services/api-version-compatibility/index.js +29 -0
  74. package/core/server/services/auth/members/index.js +1 -1
  75. package/core/server/services/mega/email-preview.js +4 -1
  76. package/core/server/services/mega/mega.js +83 -26
  77. package/core/server/services/mega/post-email-serializer.js +17 -14
  78. package/core/server/services/mega/template.js +24 -3
  79. package/core/server/services/members/api.js +2 -2
  80. package/core/server/services/members/middleware.js +69 -2
  81. package/core/server/services/members/service.js +4 -1
  82. package/core/server/services/newsletters/emails/verify-email.js +166 -0
  83. package/core/server/services/newsletters/index.js +14 -7
  84. package/core/server/services/newsletters/service.js +237 -6
  85. package/core/server/services/posts/posts-service.js +7 -9
  86. package/core/server/services/stats/service.js +2 -6
  87. package/core/server/services/users.js +20 -20
  88. package/core/server/web/admin/views/default-prod.html +4 -4
  89. package/core/server/web/admin/views/default.html +4 -4
  90. package/core/server/web/api/app.js +3 -0
  91. package/core/server/web/api/canary/admin/app.js +3 -0
  92. package/core/server/web/api/canary/admin/routes.js +3 -0
  93. package/core/server/web/api/canary/content/app.js +3 -0
  94. package/core/server/web/api/middleware/cors.js +1 -1
  95. package/core/server/web/api/v2/admin/app.js +3 -0
  96. package/core/server/web/api/v2/content/app.js +3 -0
  97. package/core/server/web/api/v3/admin/app.js +3 -0
  98. package/core/server/web/api/v3/content/app.js +3 -0
  99. package/core/server/web/members/app.js +5 -0
  100. package/core/shared/config/defaults.json +1 -1
  101. package/core/shared/labs.js +4 -2
  102. package/core/shared/settings-cache/public.js +1 -1
  103. package/package.json +69 -65
  104. package/yarn.lock +965 -620
  105. package/core/built/assets/ghost-dark-470c1ef06b10e5c40ad05f3a642eaaea.css +0 -1
  106. package/core/built/assets/ghost.min-d0c17e8314b5583c0df5d05fab3c051c.css +0 -1
  107. package/core/server/services/stats/lib/members-stats-service.js +0 -161
  108. package/core/server/services/stats/lib/mrr-stats-service.js +0 -154
@@ -0,0 +1,9 @@
1
+ const {addPermissionWithRoles} = require('../../utils');
2
+
3
+ module.exports = addPermissionWithRoles({
4
+ name: 'Read newsletters',
5
+ action: 'read',
6
+ object: 'newsletter'
7
+ }, [
8
+ 'Administrator'
9
+ ]);
@@ -0,0 +1,8 @@
1
+ const {addSetting} = require('../../utils.js');
2
+
3
+ module.exports = addSetting({
4
+ key: 'version_notifications',
5
+ value: '[]',
6
+ type: 'array',
7
+ group: 'core'
8
+ });
@@ -0,0 +1,6 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('newsletters', 'created_at', {
4
+ type: 'dateTime',
5
+ nullable: true
6
+ });
@@ -0,0 +1,6 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('newsletters', 'updated_at', {
4
+ type: 'dateTime',
5
+ nullable: true
6
+ });
@@ -0,0 +1,19 @@
1
+ const logging = require('@tryghost/logging');
2
+
3
+ const {createTransactionalMigration} = require('../../utils');
4
+
5
+ module.exports = createTransactionalMigration(
6
+ async function up(knex) {
7
+ logging.info('Setting missing created_at values for existing newsletters');
8
+
9
+ const now = knex.raw('CURRENT_TIMESTAMP');
10
+ const updatedRows = await knex('newsletters')
11
+ .where('created_at', null)
12
+ .update('created_at', now);
13
+
14
+ logging.info(`Updated ${updatedRows} newsletters with created_at = now`);
15
+ },
16
+ async function down() {
17
+ // Not required: we would lose information here.
18
+ }
19
+ );
@@ -0,0 +1,3 @@
1
+ const {createDropNullableMigration} = require('../../utils');
2
+
3
+ module.exports = createDropNullableMigration('newsletters', 'created_at');
@@ -0,0 +1,7 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('newsletters', 'show_header_name', {
4
+ type: 'boolean',
5
+ nullable: false,
6
+ defaultTo: true
7
+ });
@@ -0,0 +1,8 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('newsletters', 'uuid', {
4
+ type: 'string',
5
+ maxlength: 36,
6
+ nullable: true,
7
+ unique: true
8
+ });
@@ -0,0 +1,19 @@
1
+ const logging = require('@tryghost/logging');
2
+ const uuid = require('uuid');
3
+ const {createTransactionalMigration} = require('../../utils');
4
+
5
+ module.exports = createTransactionalMigration(
6
+ async function up(knex) {
7
+ const newslettersWithoutUUID = await knex.select('id').from('newsletters').whereNull('uuid');
8
+
9
+ logging.info(`Adding uuid field value to ${newslettersWithoutUUID.length} newsletters.`);
10
+
11
+ // eslint-disable-next-line no-restricted-syntax
12
+ for (const newsletter of newslettersWithoutUUID) {
13
+ await knex('newsletters').update('uuid', uuid.v4()).where('id', newsletter.id);
14
+ }
15
+ },
16
+ async function down() {
17
+ // Not required: we would lose information here.
18
+ }
19
+ );
@@ -0,0 +1,3 @@
1
+ const {createDropNullableMigration} = require('../../utils');
2
+
3
+ module.exports = createDropNullableMigration('newsletters', 'uuid');
@@ -0,0 +1,92 @@
1
+ const ObjectId = require('bson-objectid');
2
+ const uuid = require('uuid');
3
+ const logging = require('@tryghost/logging');
4
+ const startsWith = require('lodash/startsWith');
5
+ const {createTransactionalMigration} = require('../../utils');
6
+
7
+ module.exports = createTransactionalMigration(
8
+ async function up(knex) {
9
+ // This uses the default settings from core/server/data/schema/default-settings/default-settings.json
10
+ const newsletter = {
11
+ id: (new ObjectId()).toHexString(),
12
+ uuid: uuid.v4(),
13
+ name: 'Ghost',
14
+ description: '',
15
+ slug: 'default-newsletter',
16
+ sender_name: null,
17
+ sender_email: null,
18
+ sender_reply_to: 'newsletter',
19
+ status: 'active',
20
+ visibility: 'members',
21
+ subscribe_on_signup: true,
22
+ sort_order: 0,
23
+ body_font_category: 'sans_serif',
24
+ footer_content: '',
25
+ header_image: null,
26
+ show_badge: true,
27
+ show_feature_image: true,
28
+ show_header_icon: true,
29
+ show_header_title: true,
30
+ show_header_name: false,
31
+ title_alignment: 'center',
32
+ title_font_category: 'sans_serif',
33
+ created_at: knex.raw('CURRENT_TIMESTAMP')
34
+ };
35
+
36
+ // Make sure the newsletter table is empty
37
+ const newsletters = await knex('newsletters').count('*', {as: 'total'});
38
+
39
+ if (newsletters[0].total !== 0) {
40
+ logging.warn('Skipping adding the default newsletter - There is already at least one newsletter');
41
+ return;
42
+ }
43
+
44
+ // Get all settings in one query
45
+ const settings = await knex('settings')
46
+ .whereIn('key', [
47
+ 'title',
48
+ 'description',
49
+ 'newsletter_body_font_category',
50
+ 'newsletter_footer_content',
51
+ 'newsletter_header_image',
52
+ 'newsletter_show_badge',
53
+ 'newsletter_show_feature_image',
54
+ 'newsletter_show_header_icon',
55
+ 'newsletter_show_header_title',
56
+ 'newsletter_title_alignment',
57
+ 'newsletter_title_font_category'
58
+ ])
59
+ .select(['key', 'value']);
60
+
61
+ // eslint-disable-next-line no-restricted-syntax
62
+ for (let {key, value} of settings) {
63
+ // Use site title for the newsletter name
64
+ if (key === 'title') {
65
+ key = 'name';
66
+ }
67
+ // Settings have a `newsletter_` prefix which isn't present on the newsletters table
68
+ if (startsWith(key, 'newsletter_')) {
69
+ key = key.slice(11);
70
+ }
71
+
72
+ if (value === null && ['name', 'body_font_category', 'show_badge', 'show_feature_image', 'show_header_icon', 'show_header_title', 'title_alignment', 'title_font_category'].includes(key)) {
73
+ // Prevent setting null to non-nullable columns
74
+ // Default to defaults above in that case
75
+ continue;
76
+ }
77
+
78
+ if (typeof newsletter[key] === 'boolean') {
79
+ newsletter[key] = value === 'true';
80
+ } else {
81
+ newsletter[key] = value;
82
+ }
83
+ }
84
+
85
+ logging.info('Adding the default newsletter');
86
+ await knex('newsletters').insert(newsletter);
87
+ },
88
+ async function down(knex) {
89
+ logging.info(`Removing newsletters`);
90
+ await knex('newsletters').delete();
91
+ }
92
+ );
@@ -0,0 +1,66 @@
1
+ const logging = require('@tryghost/logging');
2
+ const ObjectID = require('bson-objectid');
3
+
4
+ const {createTransactionalMigration} = require('../../utils');
5
+
6
+ module.exports = createTransactionalMigration(
7
+ async function up(knex) {
8
+ logging.info('Adding existing subscribers to default newsletter');
9
+
10
+ const newsletter = await knex('newsletters')
11
+ .orderBy('sort_order', 'asc')
12
+ .orderBy('created_at', 'asc')
13
+ .first('id', 'name');
14
+
15
+ if (!newsletter) {
16
+ logging.info(`Default newsletter not found - skipping`);
17
+ return;
18
+ }
19
+
20
+ // This is at the start of the up() instead of at the end of the down()
21
+ // to maintain idempotency
22
+ logging.info('Removing existing newsletter subscriptions');
23
+ await knex('members_newsletters').delete();
24
+
25
+ logging.info(`Subscribing members to newsletter '${newsletter.name}'`);
26
+
27
+ const memberIds = await knex('members')
28
+ .where({subscribed: true})
29
+ .pluck('id');
30
+
31
+ if (!memberIds.length) {
32
+ logging.info(`No members to subscribe - skipping`);
33
+ return;
34
+ }
35
+
36
+ logging.info(`Found ${memberIds.length} members to subscribe`);
37
+
38
+ const pivotRows = memberIds.map((memberId) => {
39
+ return {
40
+ id: ObjectID().toHexString(),
41
+ member_id: memberId,
42
+ newsletter_id: newsletter.id
43
+ };
44
+ });
45
+
46
+ await knex.batchInsert('members_newsletters', pivotRows);
47
+ },
48
+ async function down(knex) {
49
+ logging.info('Syncing subscriptions from newsletters -> members.subscribed');
50
+ await knex('members')
51
+ .whereIn('id', function () {
52
+ this.select('member_id').from('members_newsletters');
53
+ })
54
+ .update({
55
+ subscribed: true
56
+ });
57
+ logging.info('Syncing unsubscribes from newsletters -> members.subscribed');
58
+ await knex('members')
59
+ .whereNotIn('id', function () {
60
+ this.select('member_id').from('members_newsletters');
61
+ })
62
+ .update({
63
+ subscribed: false
64
+ });
65
+ }
66
+ );
@@ -0,0 +1,9 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('members_subscribe_events', 'newsletter_id', {
4
+ type: 'string',
5
+ maxlength: 24,
6
+ nullable: true,
7
+ references: 'newsletters.id',
8
+ cascadeDelete: false
9
+ });
@@ -0,0 +1,31 @@
1
+ const logging = require('@tryghost/logging');
2
+ const {createTransactionalMigration} = require('../../utils');
3
+
4
+ module.exports = createTransactionalMigration(
5
+ async function up(knex) {
6
+ // Get the default newsletter
7
+ const newsletter = await knex('newsletters')
8
+ .where('status', 'active')
9
+ .orderBy('sort_order', 'asc')
10
+ .orderBy('created_at', 'asc')
11
+ .orderBy('id', 'asc')
12
+ .first('id');
13
+
14
+ if (!newsletter) {
15
+ logging.error(`Default newsletter not found - skipping`);
16
+ return;
17
+ }
18
+
19
+ // Set subscribe events
20
+ const updatedRows = await knex('members_subscribe_events')
21
+ .update({
22
+ newsletter_id: newsletter.id
23
+ })
24
+ .where('newsletter_id', null);
25
+
26
+ logging.info(`Updated ${updatedRows} members_subscribe_events with default newsletter id`);
27
+ },
28
+ async function down() {
29
+ // Not required
30
+ }
31
+ );
@@ -59,6 +59,18 @@ function addTableColumn(tableName, table, columnName, columnSpec = schema[tableN
59
59
  }
60
60
  }
61
61
 
62
+ function setNullable(tableName, column, transaction = db.knex) {
63
+ return transaction.schema.table(tableName, function (table) {
64
+ table.setNullable(column);
65
+ });
66
+ }
67
+
68
+ function dropNullable(tableName, column, transaction = db.knex) {
69
+ return transaction.schema.table(tableName, function (table) {
70
+ table.dropNullable(column);
71
+ });
72
+ }
73
+
62
74
  function addColumn(tableName, column, transaction = db.knex, columnSpec) {
63
75
  return transaction.schema.table(tableName, function (table) {
64
76
  addTableColumn(tableName, table, column, columnSpec);
@@ -412,6 +424,8 @@ module.exports = {
412
424
  dropForeign: dropForeign,
413
425
  addColumn: addColumn,
414
426
  dropColumn: dropColumn,
427
+ setNullable: setNullable,
428
+ dropNullable: dropNullable,
415
429
  getColumns: getColumns,
416
430
  createColumnMigration,
417
431
  // NOTE: below are exposed for testing purposes only
@@ -16,6 +16,10 @@
16
16
  "defaultValue": "[]",
17
17
  "type": "array"
18
18
  },
19
+ "version_notifications": {
20
+ "defaultValue": "[]",
21
+ "type": "array"
22
+ },
19
23
  "session_secret": {
20
24
  "defaultValue": null,
21
25
  "type": "string"
@@ -24,7 +24,8 @@
24
24
  "entries": [
25
25
  {
26
26
  "name": "Default Newsletter",
27
- "slug": "default-newsletter"
27
+ "slug": "default-newsletter",
28
+ "show_header_name": false
28
29
  }
29
30
  ]
30
31
  },
@@ -546,6 +547,11 @@
546
547
  "action_type": "browse",
547
548
  "object_type": "newsletter"
548
549
  },
550
+ {
551
+ "name": "Read newsletters",
552
+ "action_type": "read",
553
+ "object_type": "newsletter"
554
+ },
549
555
  {
550
556
  "name": "Add newsletters",
551
557
  "action_type": "add",
@@ -10,13 +10,14 @@
10
10
  module.exports = {
11
11
  newsletters: {
12
12
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
13
+ uuid: {type: 'string', maxlength: 36, nullable: false, unique: true, validations: {isUUID: true}},
13
14
  name: {type: 'string', maxlength: 191, nullable: false, unique: true},
14
15
  description: {type: 'string', maxlength: 2000, nullable: true},
15
16
  slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
16
17
  sender_name: {type: 'string', maxlength: 191, nullable: true},
17
18
  sender_email: {type: 'string', maxlength: 191, nullable: true},
18
19
  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
+ status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'active', validations: {isIn: [['active', 'archived']]}},
20
21
  visibility: {
21
22
  type: 'string',
22
23
  maxlength: 50,
@@ -33,7 +34,10 @@ module.exports = {
33
34
  show_feature_image: {type: 'bool', nullable: false, defaultTo: true},
34
35
  body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}},
35
36
  footer_content: {type: 'text', maxlength: 1000000000, nullable: true},
36
- show_badge: {type: 'bool', nullable: false, defaultTo: true}
37
+ show_badge: {type: 'bool', nullable: false, defaultTo: true},
38
+ show_header_name: {type: 'bool', nullable: false, defaultTo: true},
39
+ created_at: {type: 'dateTime', nullable: false},
40
+ updated_at: {type: 'dateTime', nullable: true}
37
41
  },
38
42
  posts: {
39
43
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
@@ -596,7 +600,8 @@ module.exports = {
596
600
  member_id: {type: 'string', maxlength: 24, nullable: false, unique: false, references: 'members.id', cascadeDelete: true},
597
601
  subscribed: {type: 'bool', nullable: false, defaultTo: true},
598
602
  created_at: {type: 'dateTime', nullable: false},
599
- source: {type: 'string', maxlength: 50, nullable: true}
603
+ source: {type: 'string', maxlength: 50, nullable: true},
604
+ newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id', cascadeDelete: false}
600
605
  },
601
606
  stripe_products: {
602
607
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
@@ -79,7 +79,7 @@ module.exports = function (Bookshelf) {
79
79
  // If it's a user, let's try to cut it down (unless this is a human request)
80
80
  if (baseName === 'user' && options && options.shortSlug && slugTryCount === 1 && slug !== 'ghost-owner') {
81
81
  longSlug = slug;
82
- slug = (slug.indexOf('-') > -1) ? slug.substr(0, slug.indexOf('-')) : slug;
82
+ slug = (slug.indexOf('-') > -1) ? slug.slice(0, slug.indexOf('-')) : slug;
83
83
  }
84
84
 
85
85
  if (!_.has(options, 'importing') || !options.importing) {
@@ -118,4 +118,4 @@ module.exports = function (Bookshelf) {
118
118
  * @property {boolean} [importing] Set to true to don't cut the slug on import
119
119
  * @property {boolean} [shortSlug] If it's a user, let's try to cut it down (unless this is a human request)
120
120
  * @property {boolean} [skipDuplicateChecks] Don't append unique identifiers when the slug is not unique (this prevents any database queries)
121
- */
121
+ */
@@ -54,6 +54,10 @@ const Email = ghostBookshelf.Model.extend({
54
54
  return this.hasMany('EmailRecipient', 'email_id');
55
55
  },
56
56
 
57
+ newsletter() {
58
+ return this.belongsTo('Newsletter', 'newsletter_id');
59
+ },
60
+
57
61
  emitChange: function emitChange(event, options) {
58
62
  const eventToTrigger = 'email' + '.' + event;
59
63
  ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
@@ -92,7 +92,7 @@ Label = ghostBookshelf.Model.extend({
92
92
  permittedOptions: function permittedOptions(methodName) {
93
93
  let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
94
94
 
95
- // whitelists for the `options` hash argument on methods, by method name.
95
+ // allowlists for the `options` hash argument on methods, by method name.
96
96
  // these are the only options that can be passed to Bookshelf / Knex.
97
97
  const validOptions = {
98
98
  findAll: ['columns'],
@@ -8,6 +8,10 @@ const MemberSubscribeEvent = ghostBookshelf.Model.extend({
8
8
  return this.belongsTo('Member', 'member_id', 'id');
9
9
  },
10
10
 
11
+ newsletter() {
12
+ return this.belongsTo('Newsletter', 'newsletter_id', 'id');
13
+ },
14
+
11
15
  customQuery(qb, options) {
12
16
  if (options.aggregateSubscriptionDeltas) {
13
17
  if (options.limit || options.filter) {
@@ -370,6 +370,32 @@ const Member = ghostBookshelf.Model.extend({
370
370
  query.transacting(unfilteredOptions.transacting);
371
371
  }
372
372
 
373
+ return query;
374
+ },
375
+
376
+ getNewsletterRelations(data, unfilteredOptions = {}) {
377
+ const query = ghostBookshelf.knex('members_newsletters')
378
+ .select('id')
379
+ .whereIn('member_id', data.memberIds);
380
+
381
+ if (unfilteredOptions.transacting) {
382
+ query.transacting(unfilteredOptions.transacting);
383
+ }
384
+
385
+ return query;
386
+ },
387
+
388
+ fetchAllSubscribed(unfilteredOptions = {}) {
389
+ // we use raw queries instead of model relationships because model hydration is expensive
390
+ const query = ghostBookshelf.knex('members_newsletters')
391
+ .join('newsletters', 'members_newsletters.newsletter_id', '=', 'newsletters.id')
392
+ .where('newsletters.status', 'active')
393
+ .distinct('member_id as id');
394
+
395
+ if (unfilteredOptions.transacting) {
396
+ query.transacting(unfilteredOptions.transacting);
397
+ }
398
+
373
399
  return query;
374
400
  }
375
401
  });
@@ -1,21 +1,40 @@
1
1
  const ghostBookshelf = require('./base');
2
+ const ObjectID = require('bson-objectid');
3
+ const uuid = require('uuid');
2
4
 
3
5
  const Newsletter = ghostBookshelf.Model.extend({
4
6
  tableName: 'newsletters',
5
7
 
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
- show_header_icon: true,
18
- show_header_title: true
8
+ defaults: function defaults() {
9
+ return {
10
+ uuid: uuid.v4(),
11
+ sender_reply_to: 'newsletter',
12
+ status: 'active',
13
+ visibility: 'members',
14
+ subscribe_on_signup: true,
15
+ sort_order: 0,
16
+ title_font_category: 'sans_serif',
17
+ title_alignment: 'center',
18
+ show_feature_image: true,
19
+ body_font_category: 'sans_serif',
20
+ show_badge: true,
21
+ show_header_icon: true,
22
+ show_header_title: true,
23
+ show_header_name: true
24
+ };
25
+ },
26
+
27
+ members() {
28
+ return this.belongsToMany('Member', 'members_newsletters', 'newsletter_id', 'member_id')
29
+ .query((qb) => {
30
+ // avoids bookshelf adding a `DISTINCT` to the query
31
+ // we know the result set will already be unique and DISTINCT hurts query performance
32
+ qb.columns('members.*');
33
+ });
34
+ },
35
+
36
+ posts() {
37
+ return this.hasMany('Post');
19
38
  },
20
39
 
21
40
  async onSaving(model, _attr, options) {
@@ -36,12 +55,76 @@ const Newsletter = ghostBookshelf.Model.extend({
36
55
  model.set({slug: cleanSlug});
37
56
  }
38
57
  }
58
+ },
59
+
60
+ subscribeMembersById(memberIds, unfilteredOptions = {}) {
61
+ let pivotRows = [];
62
+ for (const memberId of memberIds) {
63
+ pivotRows.push({
64
+ id: ObjectID().toHexString(),
65
+ member_id: memberId.id,
66
+ newsletter_id: this.id
67
+ });
68
+ }
69
+
70
+ const query = ghostBookshelf.knex.batchInsert('members_newsletters', pivotRows);
71
+
72
+ if (unfilteredOptions.transacting) {
73
+ query.transacting(unfilteredOptions.transacting);
74
+ }
75
+
76
+ return query;
39
77
  }
40
78
  }, {
79
+ orderDefaultRaw: function () {
80
+ return 'sort_order ASC, created_at ASC, id ASC';
81
+ },
82
+
41
83
  orderDefaultOptions: function orderDefaultOptions() {
42
84
  return {
43
- sort_order: 'ASC'
85
+ sort_order: 'ASC',
86
+ created_at: 'ASC',
87
+ id: 'ASC'
44
88
  };
89
+ },
90
+
91
+ getDefaultNewsletter: async function getDefaultNewsletter(unfilteredOptions = {}) {
92
+ const options = {
93
+ filter: 'status:active',
94
+ order: this.orderDefaultRaw(),
95
+ limit: 1
96
+ };
97
+
98
+ if (unfilteredOptions.transacting) {
99
+ options.transacting = unfilteredOptions.transacting;
100
+ }
101
+
102
+ const newsletters = await this.findPage(options);
103
+
104
+ if (newsletters.data.length > 0) {
105
+ return newsletters.data[0];
106
+ }
107
+ return null;
108
+ },
109
+
110
+ getNextAvailableSortOrder: async function getNextAvailableSortOrder(unfilteredOptions = {}) {
111
+ const options = {
112
+ filter: 'status:active',
113
+ order: 'sort_order DESC', // there's no NQL syntax available here
114
+ limit: 1,
115
+ columns: ['sort_order']
116
+ };
117
+
118
+ if (unfilteredOptions.transacting) {
119
+ options.transacting = unfilteredOptions.transacting;
120
+ }
121
+
122
+ const lastNewsletter = await this.findPage(options);
123
+
124
+ if (lastNewsletter.data.length > 0) {
125
+ return lastNewsletter.data[0].get('sort_order') + 1;
126
+ }
127
+ return 0;
45
128
  }
46
129
  });
47
130