ghost 4.42.1 → 4.44.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 (128) 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-470c1ef06b10e5c40ad05f3a642eaaea.css +1 -0
  4. package/core/built/assets/{ghost.min-20096eef632760c3a2906e243adbd24b.js → ghost.min-1e7dce606e92a03207d15ae7eb3d3c23.js} +411 -323
  5. package/core/built/assets/ghost.min-d0c17e8314b5583c0df5d05fab3c051c.css +1 -0
  6. package/core/built/assets/{vendor.min-21f79c68a284acb1b70039f3f63e5507.js → vendor.min-fe2c9b1235b4119b5406b788db2db434.js} +88 -82
  7. package/core/frontend/apps/amp/lib/helpers/amp_analytics.js +1 -1
  8. package/core/frontend/apps/amp/lib/helpers/amp_components.js +1 -1
  9. package/core/frontend/apps/amp/lib/helpers/amp_content.js +1 -1
  10. package/core/frontend/apps/amp/lib/helpers/amp_style.js +1 -1
  11. package/core/frontend/apps/amp/lib/router.js +6 -5
  12. package/core/frontend/apps/private-blogging/lib/helpers/input_password.js +1 -1
  13. package/core/frontend/apps/private-blogging/lib/router.js +2 -2
  14. package/core/frontend/helpers/asset.js +1 -1
  15. package/core/frontend/helpers/author.js +1 -1
  16. package/core/frontend/helpers/authors.js +1 -1
  17. package/core/frontend/helpers/body_class.js +1 -1
  18. package/core/frontend/helpers/cancel_link.js +1 -1
  19. package/core/frontend/helpers/concat.js +1 -1
  20. package/core/frontend/helpers/content.js +1 -1
  21. package/core/frontend/helpers/date.js +1 -1
  22. package/core/frontend/helpers/encode.js +1 -1
  23. package/core/frontend/helpers/excerpt.js +1 -1
  24. package/core/frontend/helpers/facebook_url.js +1 -1
  25. package/core/frontend/helpers/foreach.js +2 -2
  26. package/core/frontend/helpers/get.js +1 -1
  27. package/core/frontend/helpers/ghost_foot.js +1 -1
  28. package/core/frontend/helpers/ghost_head.js +1 -1
  29. package/core/frontend/helpers/lang.js +1 -1
  30. package/core/frontend/helpers/link.js +1 -1
  31. package/core/frontend/helpers/link_class.js +1 -1
  32. package/core/frontend/helpers/match.js +1 -1
  33. package/core/frontend/helpers/navigation.js +1 -1
  34. package/core/frontend/helpers/pagination.js +1 -1
  35. package/core/frontend/helpers/plural.js +1 -1
  36. package/core/frontend/helpers/post_class.js +1 -1
  37. package/core/frontend/helpers/prev_post.js +6 -5
  38. package/core/frontend/helpers/products.js +1 -1
  39. package/core/frontend/helpers/reading_time.js +2 -2
  40. package/core/frontend/helpers/t.js +1 -1
  41. package/core/frontend/helpers/tags.js +1 -1
  42. package/core/frontend/helpers/tiers.js +1 -1
  43. package/core/frontend/helpers/title.js +1 -1
  44. package/core/frontend/helpers/twitter_url.js +1 -1
  45. package/core/frontend/helpers/url.js +1 -1
  46. package/core/frontend/meta/url.js +4 -4
  47. package/core/{server/data/schema → frontend/services/data}/checks.js +4 -4
  48. package/core/frontend/services/{routing/helpers → data}/entry-lookup.js +3 -3
  49. package/core/frontend/services/{routing/helpers → data}/fetch-data.js +3 -3
  50. package/core/frontend/services/data/index.js +5 -0
  51. package/core/frontend/services/{rendering.js → handlebars.js} +2 -1
  52. package/core/frontend/services/helpers/handlebars.js +1 -1
  53. package/core/frontend/services/proxy.js +2 -4
  54. package/core/frontend/services/{routing/helpers → rendering}/context.js +0 -0
  55. package/core/frontend/services/{routing/helpers → rendering}/error.js +0 -0
  56. package/core/frontend/services/{routing/helpers → rendering}/format-response.js +1 -1
  57. package/core/frontend/services/{routing/helpers → rendering}/index.js +0 -8
  58. package/core/frontend/services/{routing/helpers → rendering}/render-entries.js +1 -1
  59. package/core/frontend/services/{routing/helpers → rendering}/render-entry.js +1 -1
  60. package/core/frontend/services/{routing/helpers → rendering}/renderer.js +1 -1
  61. package/core/frontend/services/{routing/helpers → rendering}/secure.js +0 -0
  62. package/core/frontend/services/{routing/helpers → rendering}/templates.js +2 -2
  63. package/core/frontend/services/routing/CollectionRouter.js +1 -1
  64. package/core/frontend/services/routing/controllers/channel.js +9 -9
  65. package/core/frontend/services/routing/controllers/collection.js +9 -9
  66. package/core/frontend/services/routing/controllers/email-post.js +5 -6
  67. package/core/frontend/services/routing/controllers/entry.js +6 -6
  68. package/core/frontend/services/routing/controllers/preview.js +5 -6
  69. package/core/frontend/services/routing/controllers/rss.js +4 -3
  70. package/core/frontend/services/routing/controllers/static.js +5 -5
  71. package/core/frontend/services/routing/controllers/unsubscribe.js +2 -2
  72. package/core/frontend/services/routing/index.js +0 -4
  73. package/core/frontend/web/middleware/error-handler.js +2 -2
  74. package/core/server/api/canary/authentication.js +2 -2
  75. package/core/server/api/canary/posts.js +1 -0
  76. package/core/server/api/canary/stats.js +9 -0
  77. package/core/server/api/canary/utils/serializers/output/members.js +8 -0
  78. package/core/server/api/canary/utils/validators/input/index.js +6 -0
  79. package/core/server/api/shared/http.js +52 -51
  80. package/core/server/data/exporter/table-lists.js +1 -0
  81. package/core/server/data/migrations/utils.js +33 -1
  82. package/core/server/data/migrations/versions/4.42/2022-03-21-17-17-add.js +5 -0
  83. package/core/server/data/migrations/versions/4.43/2022-03-28-19-26-recreate-newsletter-table.js +29 -0
  84. package/core/server/data/migrations/versions/4.43/2022-03-29-14-45-add-members-newsletters-table.js +7 -0
  85. package/core/server/data/migrations/versions/4.43/2022-04-01-10-13-add-post-newsletter-relation.js +108 -0
  86. package/core/server/data/migrations/versions/4.43/2022-04-06-09-47-add-type-column-to-paid-subscription-events.js +7 -0
  87. package/core/server/data/migrations/versions/4.43/2022-04-06-14-56-add-email-newsletter-relation.js +8 -0
  88. package/core/server/data/migrations/versions/4.43/2022-04-08-10-45-add-subscription-id-to-mrr-events.js +7 -0
  89. package/core/server/data/migrations/versions/4.44/2022-04-06-15-22-populate-type-column-for-paid-subscription-events.js +21 -0
  90. package/core/server/data/migrations/versions/4.44/2022-04-08-11-54-add-cancelled-events.js +51 -0
  91. package/core/server/data/migrations/versions/4.44/2022-04-11-08-24-add-newsletter-permissions.js +33 -0
  92. package/core/server/data/migrations/versions/4.44/2022-04-11-10-54-add-mrr-to-subscriptions.js +8 -0
  93. package/core/server/data/migrations/versions/4.44/2022-04-12-07-33-fill-mrr.js +29 -0
  94. package/core/server/data/migrations/versions/4.44/2022-04-13-12-00-remove-newsletter-sender-name-not-null-constraint.js +33 -0
  95. package/core/server/data/migrations/versions/4.44/2022-04-15-07-53-add-offer-id-to-subscriptions.js +9 -0
  96. package/core/server/data/schema/commands.js +6 -1
  97. package/core/server/data/schema/fixtures/fixtures.json +26 -1
  98. package/core/server/data/schema/index.js +0 -1
  99. package/core/server/data/schema/schema.js +36 -16
  100. package/core/server/models/base/bookshelf.js +1 -1
  101. package/core/server/models/base/plugins/crud.js +8 -0
  102. package/core/server/models/member.js +21 -1
  103. package/core/server/models/newsletter.js +42 -1
  104. package/core/server/models/post.js +12 -2
  105. package/core/server/models/stripe-customer-subscription.js +4 -0
  106. package/core/server/services/auth/setup.js +21 -8
  107. package/core/server/services/mega/mega.js +3 -1
  108. package/core/server/services/members/api.js +3 -1
  109. package/core/server/services/members/middleware.js +13 -3
  110. package/core/server/services/members/service.js +3 -11
  111. package/core/server/services/members/utils.js +13 -1
  112. package/core/server/services/newsletters/index.js +10 -0
  113. package/core/server/services/newsletters/service.js +24 -0
  114. package/core/server/services/posts/posts-service.js +20 -1
  115. package/core/server/services/slack.js +11 -3
  116. package/core/server/services/stats/lib/members-stats-service.js +30 -34
  117. package/core/server/services/stats/lib/mrr-stats-service.js +154 -0
  118. package/core/server/services/stats/service.js +3 -1
  119. package/core/server/services/stripe/service.js +1 -0
  120. package/core/server/web/admin/views/default-prod.html +4 -4
  121. package/core/server/web/admin/views/default.html +4 -4
  122. package/core/server/web/api/canary/admin/routes.js +1 -0
  123. package/core/shared/config/defaults.json +2 -2
  124. package/package.json +39 -39
  125. package/yarn.lock +410 -369
  126. package/content/themes/casper/assets/css/csscomb.json +0 -240
  127. package/core/built/assets/ghost-dark-a93afb20027060d760ac6d78f115a76f.css +0 -1
  128. package/core/built/assets/ghost.min-ce35ef1b76d9a943ab912c076773b132.css +0 -1
@@ -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
+ });
@@ -0,0 +1,21 @@
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 "type" to "updated" for events with different to_plan & from_plan');
8
+ await knex('members_paid_subscription_events').update('type', 'updated').whereNotNull('from_plan').whereNotNull('to_plan').whereRaw('to_plan != from_plan');
9
+
10
+ logging.info('Setting "type" to "expired" for events with null to_plan or the same to_plan & from_plan');
11
+ await knex('members_paid_subscription_events').update('type', 'expired').whereNull('to_plan').whereNotNull('from_plan');
12
+ await knex('members_paid_subscription_events').update('type', 'expired').whereRaw('from_plan = to_plan');
13
+
14
+ logging.info('Setting "type" to "created" for events with null from_plan');
15
+ await knex('members_paid_subscription_events').update('type', 'created').whereNull('from_plan').whereNotNull('to_plan');
16
+ },
17
+ async function down(knex) {
18
+ logging.info('Setting "type" to null for all rows in "members_paid_subscriptions events"');
19
+ await knex('members_paid_subscription_events').update('type', null);
20
+ }
21
+ );
@@ -0,0 +1,51 @@
1
+ const ObjectID = require('bson-objectid').default;
2
+ const logging = require('@tryghost/logging');
3
+
4
+ const {createTransactionalMigration} = require('../../utils');
5
+
6
+ module.exports = createTransactionalMigration(
7
+ async function up(knex) {
8
+ const cancelledSubscriptions = await knex
9
+ .select(
10
+ 'members.id as member_id',
11
+ 'members_stripe_customers_subscriptions.id',
12
+ 'members_stripe_customers_subscriptions.stripe_price_id',
13
+ 'members_stripe_customers_subscriptions.plan_currency',
14
+ 'members_stripe_customers_subscriptions.updated_at'
15
+ )
16
+ .from('members_stripe_customers_subscriptions')
17
+ .join('members_stripe_customers', 'members_stripe_customers.customer_id', '=', 'members_stripe_customers_subscriptions.customer_id')
18
+ .join('members', 'members_stripe_customers.member_id', '=', 'members.id')
19
+ .where('members_stripe_customers_subscriptions.cancel_at_period_end', true)
20
+ .whereNot('members_stripe_customers_subscriptions.status', 'canceled');
21
+
22
+ if (cancelledSubscriptions.length === 0) {
23
+ logging.info('No missing cancelled events - skipping migration');
24
+ return;
25
+ }
26
+
27
+ const eventsToInsert = cancelledSubscriptions.map((subscription) => {
28
+ const event = {
29
+ id: (new ObjectID()).toHexString(),
30
+ type: 'canceled',
31
+ member_id: subscription.member_id,
32
+ subscription_id: subscription.id,
33
+ from_plan: subscription.stripe_price_id,
34
+ to_plan: subscription.stripe_price_id,
35
+ currency: subscription.plan_currency,
36
+ source: 'migration',
37
+ mrr_delta: 0,
38
+ created_at: subscription.updated_at
39
+ };
40
+
41
+ return event;
42
+ });
43
+
44
+ logging.info(`Found ${eventsToInsert.length} missing cancellation events`);
45
+ await knex('members_paid_subscription_events').insert(eventsToInsert);
46
+ },
47
+ async function down(knex) {
48
+ logging.info('Deleting all members_paid_subscription_events with a "type" of "cancelled"');
49
+ await knex('members_paid_subscription_events').where({type: 'canceled', source: 'migration'}).del();
50
+ }
51
+ );
@@ -0,0 +1,33 @@
1
+ const {
2
+ addPermissionWithRoles,
3
+ combineTransactionalMigrations
4
+ } = require('../../utils');
5
+
6
+ /**
7
+ * This is similar to core/server/data/migrations/versions/4.42/2022-03-30-15-44-add-newsletter-permissions.js
8
+ * as the permissions were not added in the fixture file at the time of the migration.
9
+ * This means the new Ghost installs do not have the newsletter permission and we need this migration.
10
+ */
11
+ module.exports = combineTransactionalMigrations(
12
+ addPermissionWithRoles({
13
+ name: 'Browse newsletters',
14
+ action: 'browse',
15
+ object: 'newsletter'
16
+ }, [
17
+ 'Administrator'
18
+ ]),
19
+ addPermissionWithRoles({
20
+ name: 'Add newsletters',
21
+ action: 'add',
22
+ object: 'newsletter'
23
+ }, [
24
+ 'Administrator'
25
+ ]),
26
+ addPermissionWithRoles({
27
+ name: 'Edit newsletters',
28
+ action: 'edit',
29
+ object: 'newsletter'
30
+ }, [
31
+ 'Administrator'
32
+ ])
33
+ );
@@ -0,0 +1,8 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('members_stripe_customers_subscriptions', 'mrr', {
4
+ type: 'integer',
5
+ unsigned: true,
6
+ nullable: false,
7
+ defaultTo: 0
8
+ });
@@ -0,0 +1,29 @@
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 "mrr" for active subscriptions in "members_stripe_customers_subscriptions"');
8
+
9
+ // Note that we also set the MRR for 'canceled' subscriptions (cancel_at_period_end === true)
10
+ // A different migration will make that change in 5.0
11
+ await knex('members_stripe_customers_subscriptions')
12
+ .update('mrr', knex.raw(`
13
+ CASE WHEN plan_interval = 'year' THEN
14
+ FLOOR(plan_amount / 12)
15
+ WHEN plan_interval = 'week' THEN
16
+ plan_amount * 4
17
+ WHEN plan_interval = 'day' THEN
18
+ plan_amount * 30
19
+ ELSE
20
+ plan_amount
21
+ END
22
+ `))
23
+ .whereNotIn('status', ['trialing', 'incomplete', 'incomplete_expired', 'canceled']);
24
+ },
25
+ async function down(knex) {
26
+ logging.info('Setting "mrr" to 0 for all rows in "members_stripe_customers_subscriptions"');
27
+ await knex('members_stripe_customers_subscriptions').update('mrr', 0);
28
+ }
29
+ );
@@ -0,0 +1,33 @@
1
+ const logging = require('@tryghost/logging');
2
+ const {createNonTransactionalMigration} = require('../../utils');
3
+
4
+ /**
5
+ * Note: This doesn't use knex.alterTable as it doesn't work for down migration.
6
+ * It tries to insert a `null` into non `nullable` column while altering column
7
+ */
8
+ module.exports = createNonTransactionalMigration(
9
+ async function up(knex) {
10
+ logging.info('Dropping NOT NULL constraint for: sender_name in table: newsletters');
11
+
12
+ await knex.schema.table('newsletters', function (table) {
13
+ table.dropColumn('sender_name');
14
+ });
15
+
16
+ await knex.schema.table('newsletters', function (table) {
17
+ table.string('sender_name', 191).nullable();
18
+ });
19
+ },
20
+ async function down(knex) {
21
+ logging.info('Adding NOT NULL constraint for: sender_name in table: newsletters');
22
+
23
+ await knex.schema.table('newsletters', function (table) {
24
+ table.dropColumn('sender_name');
25
+ });
26
+
27
+ await knex.schema.table('newsletters', function (table) {
28
+ // SQLite doesn't allow adding a non nullable column without any default
29
+ table.string('sender_name', 191).notNullable().defaultTo('Ghost');
30
+ });
31
+ }
32
+ );
33
+
@@ -0,0 +1,9 @@
1
+ const {createAddColumnMigration} = require('../../utils');
2
+
3
+ module.exports = createAddColumnMigration('members_stripe_customers_subscriptions', 'offer_id', {
4
+ type: 'string',
5
+ maxlength: 24,
6
+ nullable: true,
7
+ unique: false,
8
+ references: 'offers.id'
9
+ });
@@ -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
  });
@@ -19,6 +19,15 @@
19
19
  }
20
20
  ]
21
21
  },
22
+ {
23
+ "name": "Newsletter",
24
+ "entries": [
25
+ {
26
+ "name": "Default Newsletter",
27
+ "slug": "default-newsletter"
28
+ }
29
+ ]
30
+ },
22
31
  {
23
32
  "name": "Tag",
24
33
  "entries": [
@@ -531,6 +540,21 @@
531
540
  "name": "Edit custom theme settings",
532
541
  "action_type": "edit",
533
542
  "object_type": "custom_theme_setting"
543
+ },
544
+ {
545
+ "name": "Browse newsletters",
546
+ "action_type": "browse",
547
+ "object_type": "newsletter"
548
+ },
549
+ {
550
+ "name": "Add newsletters",
551
+ "action_type": "add",
552
+ "object_type": "newsletter"
553
+ },
554
+ {
555
+ "name": "Edit newsletters",
556
+ "action_type": "edit",
557
+ "object_type": "newsletter"
534
558
  }
535
559
  ]
536
560
  },
@@ -642,7 +666,8 @@
642
666
  "custom_theme_setting": "all",
643
667
  "offer": "all",
644
668
  "authentication": "resetAllPasswords",
645
- "members_stripe_connect": "auth"
669
+ "members_stripe_connect": "auth",
670
+ "newsletter": "all"
646
671
  },
647
672
  "DB Backup Integration": {
648
673
  "db": "all"
@@ -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: true},
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},
@@ -545,6 +575,8 @@ module.exports = {
545
575
  created_by: {type: 'string', maxlength: 24, nullable: false},
546
576
  updated_at: {type: 'dateTime', nullable: true},
547
577
  updated_by: {type: 'string', maxlength: 24, nullable: true},
578
+ mrr: {type: 'integer', unsigned: true, nullable: false, defaultTo: 0},
579
+ offer_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'offers.id'},
548
580
  /* Below fields are now redundant as we link prie_id to stripe_prices table */
549
581
  plan_id: {type: 'string', maxlength: 255, nullable: false, unique: false},
550
582
  plan_nickname: {type: 'string', maxlength: 50, nullable: false},
@@ -630,6 +662,7 @@ module.exports = {
630
662
  plaintext: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true},
631
663
  track_opens: {type: 'bool', nullable: false, defaultTo: false},
632
664
  submitted_at: {type: 'dateTime', nullable: false},
665
+ newsletter_id: {type: 'string', maxlength: 24, nullable: true, references: 'newsletters.id'},
633
666
  created_at: {type: 'dateTime', nullable: false},
634
667
  created_by: {type: 'string', maxlength: 24, nullable: false},
635
668
  updated_at: {type: 'dateTime', nullable: true},
@@ -712,22 +745,9 @@ module.exports = {
712
745
  },
713
746
  value: {type: 'text', maxlength: 65535, nullable: true}
714
747
  },
715
- newsletters: {
748
+ members_newsletters: {
716
749
  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}
750
+ member_id: {type: 'string', maxlength: 24, nullable: false, references: 'members.id', cascadeDelete: true},
751
+ newsletter_id: {type: 'string', maxlength: 24, nullable: false, references: 'newsletters.id', cascadeDelete: true}
732
752
  }
733
753
  };
@@ -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';
@@ -30,6 +30,9 @@ const Member = ghostBookshelf.Model.extend({
30
30
  }, {
31
31
  key: 'products',
32
32
  replacement: 'products.slug'
33
+ }, {
34
+ key: 'newsletters',
35
+ replacement: 'newsletters.slug'
33
36
  }];
34
37
  },
35
38
 
@@ -49,6 +52,13 @@ const Member = ghostBookshelf.Model.extend({
49
52
  joinFrom: 'member_id',
50
53
  joinTo: 'product_id'
51
54
  },
55
+ newsletters: {
56
+ tableName: 'newsletters',
57
+ type: 'manyToMany',
58
+ joinTable: 'members_newsletters',
59
+ joinFrom: 'member_id',
60
+ joinTo: 'newsletter_id'
61
+ },
52
62
  subscriptions: {
53
63
  tableName: 'members_stripe_customers_subscriptions',
54
64
  tableNameAs: 'subscriptions',
@@ -61,7 +71,7 @@ const Member = ghostBookshelf.Model.extend({
61
71
  };
62
72
  },
63
73
 
64
- relationships: ['products', 'labels', 'stripeCustomers', 'email_recipients'],
74
+ relationships: ['products', 'labels', 'stripeCustomers', 'email_recipients', 'newsletters'],
65
75
 
66
76
  // do not delete email_recipients records when a member is destroyed. Recipient
67
77
  // records are used for analytics and historical records
@@ -73,6 +83,7 @@ const Member = ghostBookshelf.Model.extend({
73
83
 
74
84
  relationshipBelongsTo: {
75
85
  products: 'products',
86
+ newsletters: 'newsletters',
76
87
  labels: 'labels',
77
88
  stripeCustomers: 'members_stripe_customers',
78
89
  email_recipients: 'email_recipients'
@@ -94,6 +105,15 @@ const Member = ghostBookshelf.Model.extend({
94
105
  });
95
106
  },
96
107
 
108
+ newsletters() {
109
+ return this.belongsToMany('Newsletter', 'members_newsletters', 'member_id', 'newsletter_id')
110
+ .query((qb) => {
111
+ // avoids bookshelf adding a `DISTINCT` to the query
112
+ // we know the result set will already be unique and DISTINCT hurts query performance
113
+ qb.columns('newsletters.*');
114
+ });
115
+ },
116
+
97
117
  offerRedemptions() {
98
118
  return this.hasMany('OfferRedemption', 'member_id', 'id')
99
119
  .query('orderBy', 'created_at', 'DESC');
@@ -1,7 +1,48 @@
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
+ show_header_icon: true,
18
+ show_header_title: true
19
+ },
20
+
21
+ async onSaving(model, _attr, options) {
22
+ ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);
23
+
24
+ if (model.get('name')) {
25
+ model.set('name', model.get('name').trim());
26
+ }
27
+
28
+ if (model.hasChanged('slug') || !model.get('slug')) {
29
+ const slug = model.get('slug') || model.get('name');
30
+
31
+ if (slug) {
32
+ const cleanSlug = await ghostBookshelf.Model.generateSlug(Newsletter, slug, {
33
+ transacting: options.transacting
34
+ });
35
+
36
+ model.set({slug: cleanSlug});
37
+ }
38
+ }
39
+ }
40
+ }, {
41
+ orderDefaultOptions: function orderDefaultOptions() {
42
+ return {
43
+ sort_order: 'ASC'
44
+ };
45
+ }
5
46
  });
6
47
 
7
48
  module.exports = {