ghost 4.13.0 → 4.16.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 (112) hide show
  1. package/content/themes/casper/assets/built/screen.css +1 -1
  2. package/content/themes/casper/assets/built/screen.css.map +1 -1
  3. package/content/themes/casper/assets/css/screen.css +1 -1
  4. package/content/themes/casper/default.hbs +2 -2
  5. package/content/themes/casper/package.json +1 -1
  6. package/content/themes/casper/page.hbs +28 -26
  7. package/content/themes/casper/partials/post-card.hbs +2 -2
  8. package/content/themes/casper/post.hbs +67 -65
  9. package/content/themes/casper/tag.hbs +2 -2
  10. package/core/built/assets/{chunk.3.f80c7fbb7573ce508a05.js → chunk.3.4b1d9e20e57164ac9c29.js} +31 -29
  11. package/core/built/assets/ghost-dark-bb2831fc27fcb02893ed0a761207dc63.css +1 -0
  12. package/core/built/assets/{ghost.min-ba7f03a78d7d98444af386b8ae9347a7.js → ghost.min-d1d99f3ed6e0f427874b2a11e7078475.js} +1777 -1685
  13. package/core/built/assets/ghost.min-e7612edfa72b0fe2c201b387923e6fc7.css +1 -0
  14. package/core/built/assets/icons/check-2.svg +1 -0
  15. package/core/built/assets/icons/discount-bubble.svg +1 -0
  16. package/core/built/assets/icons/no-data-line-chart.svg +1 -0
  17. package/core/built/assets/icons/no-data-list.svg +9 -8
  18. package/core/built/assets/icons/no-data-subscription.svg +1 -0
  19. package/core/built/assets/{vendor.min-29784d514390cb5abc74ae660cb2fbc7.js → vendor.min-3660ec7864887f1496fe7a27fd23ab76.js} +1570 -1289
  20. package/core/frontend/helpers/ghost_head.js +7 -1
  21. package/core/frontend/helpers/match.js +19 -4
  22. package/core/frontend/helpers/products.js +68 -0
  23. package/core/frontend/helpers/tpl/content-cta.hbs +1 -1
  24. package/core/frontend/services/routing/controllers/email-post.js +3 -2
  25. package/core/frontend/services/settings/loader.js +2 -2
  26. package/core/frontend/services/sitemap/base-generator.js +12 -8
  27. package/core/frontend/services/sitemap/manager.js +1 -1
  28. package/core/frontend/services/theme-engine/handlebars/helpers.js +1 -0
  29. package/core/frontend/services/theme-engine/middleware.js +4 -1
  30. package/core/server/api/canary/email-preview.js +15 -33
  31. package/core/server/api/canary/integrations.js +7 -30
  32. package/core/server/api/canary/labels.js +8 -9
  33. package/core/server/api/canary/members.js +13 -9
  34. package/core/server/api/canary/schedules.js +9 -57
  35. package/core/server/api/canary/settings.js +20 -158
  36. package/core/server/api/canary/themes.js +5 -59
  37. package/core/server/api/canary/utils/serializers/output/members.js +2 -14
  38. package/core/server/api/canary/utils/validators/input/settings.js +23 -1
  39. package/core/server/api/canary/webhooks.js +6 -24
  40. package/core/server/api/v2/schedules.js +9 -57
  41. package/core/server/api/v3/email-preview.js +15 -28
  42. package/core/server/api/v3/integrations.js +7 -30
  43. package/core/server/api/v3/labels.js +8 -9
  44. package/core/server/api/v3/members.js +4 -1
  45. package/core/server/api/v3/schedules.js +9 -57
  46. package/core/server/api/v3/settings.js +13 -132
  47. package/core/server/api/v3/utils/validators/input/settings.js +23 -1
  48. package/core/server/api/v3/webhooks.js +6 -28
  49. package/core/server/data/exporter/table-lists.js +1 -0
  50. package/core/server/data/importer/import-manager.js +398 -0
  51. package/core/server/data/importer/importers/data/data-importer.js +162 -0
  52. package/core/server/data/importer/importers/data/index.js +1 -162
  53. package/core/server/data/importer/index.js +1 -379
  54. package/core/server/data/migrations/versions/4.14/01-fix-comped-member-statuses.js +70 -0
  55. package/core/server/data/migrations/versions/4.14/02-fix-free-members-status-events.js +60 -0
  56. package/core/server/data/migrations/versions/4.15/01-add-temp-members-analytic-events-table.js +12 -0
  57. package/core/server/data/migrations/versions/4.16/01-add-custom-theme-settings-table.js +9 -0
  58. package/core/server/data/schema/fixtures/utils.js +6 -1
  59. package/core/server/data/schema/schema.js +26 -0
  60. package/core/server/lib/request-external.js +3 -2
  61. package/core/server/models/action.js +1 -1
  62. package/core/server/models/api-key.js +1 -1
  63. package/core/server/models/base/bookshelf.js +0 -3
  64. package/core/server/models/base/index.js +2 -0
  65. package/core/server/models/base/plugins/events.js +2 -2
  66. package/core/server/models/base/plugins/raw-knex.js +10 -10
  67. package/core/server/models/custom-theme-setting.js +9 -0
  68. package/core/server/models/email.js +2 -2
  69. package/core/server/models/index.js +2 -0
  70. package/core/server/models/integration.js +1 -1
  71. package/core/server/models/label.js +2 -2
  72. package/core/server/models/member-analytic-event.js +9 -0
  73. package/core/server/models/member.js +2 -2
  74. package/core/server/models/post.js +2 -2
  75. package/core/server/models/settings.js +2 -2
  76. package/core/server/models/tag.js +2 -2
  77. package/core/server/models/user.js +2 -2
  78. package/core/server/models/webhook.js +2 -2
  79. package/core/server/services/bulk-email/bulk-email-processor.js +1 -4
  80. package/core/server/services/custom-theme-settings.js +8 -0
  81. package/core/server/services/integrations/integrations-service.js +61 -0
  82. package/core/server/services/mail/GhostMailer.js +29 -37
  83. package/core/server/services/mega/email-preview.js +41 -0
  84. package/core/server/services/mega/index.js +4 -0
  85. package/core/server/services/mega/mega.js +27 -23
  86. package/core/server/services/mega/post-email-serializer.js +28 -21
  87. package/core/server/services/mega/template.js +11 -0
  88. package/core/server/services/members/api.js +1 -0
  89. package/core/server/services/members/emails/signup.js +1 -1
  90. package/core/server/services/oembed.js +7 -2
  91. package/core/server/services/posts/post-scheduling-service.js +100 -0
  92. package/core/server/services/settings/index.js +13 -16
  93. package/core/server/services/settings/settings-bread-service.js +188 -0
  94. package/core/server/services/settings/settings-utils.js +32 -0
  95. package/core/server/services/themes/ThemeStorage.js +5 -4
  96. package/core/server/services/themes/activation-bridge.js +14 -0
  97. package/core/server/services/themes/index.js +2 -0
  98. package/core/server/services/themes/installer.js +72 -0
  99. package/core/server/services/themes/validate.js +5 -2
  100. package/core/server/services/webhooks/webhooks-service.js +55 -0
  101. package/core/server/web/admin/views/default-prod.html +4 -4
  102. package/core/server/web/admin/views/default.html +4 -4
  103. package/core/server/web/members/app.js +3 -0
  104. package/core/shared/config/defaults.json +2 -2
  105. package/core/shared/custom-theme-settings-cache.js +3 -0
  106. package/core/shared/i18n/translations/en.json +1 -6
  107. package/core/shared/labs.js +5 -7
  108. package/package.json +64 -62
  109. package/yarn.lock +1490 -1055
  110. package/core/built/assets/ghost-dark-98d56e4973a502750748090f9dbc8280.css +0 -1
  111. package/core/built/assets/ghost.min-6932a664a1cb92a8e4a15f540cae3ad8.css +0 -1
  112. package/core/server/services/mega/template-labs.js +0 -1024
@@ -2,7 +2,7 @@
2
2
  // Usage: `{{ghost_head}}`
3
3
  //
4
4
  // Outputs scripts and other assets at the top of a Ghost theme
5
- const {metaData, escapeExpression, SafeString, logging, settingsCache, config, blogIcon, urlUtils} = require('../services/proxy');
5
+ const {metaData, escapeExpression, SafeString, logging, settingsCache, config, blogIcon, urlUtils, labs} = require('../services/proxy');
6
6
  const _ = require('lodash');
7
7
  const debug = require('@tryghost/debug')('ghost_head');
8
8
  const templateStyles = require('./tpl/styles');
@@ -176,6 +176,12 @@ module.exports = function ghost_head(options) { // eslint-disable-line camelcase
176
176
  head.push('<meta name="generator" content="Ghost ' +
177
177
  escapeExpression(safeVersion) + '" />');
178
178
 
179
+ // Ghost analytics tag
180
+ if (labs.isSet('membersActivity')) {
181
+ const postId = (dataRoot && dataRoot.post) ? dataRoot.post.id : '';
182
+ head.push(writeMetaTag('ghost-analytics-id', postId, 'name'));
183
+ }
184
+
179
185
  head.push('<link rel="alternate" type="application/rss+xml" title="' +
180
186
  escapeExpression(meta.site.title) + '" href="' +
181
187
  escapeExpression(meta.rssUrl) + '" />');
@@ -2,20 +2,35 @@ const {logging, i18n, SafeString, labs} = require('../services/proxy');
2
2
  const _ = require('lodash');
3
3
 
4
4
  /**
5
- * This is identical to the built-in if helper
5
+ * This is identical to the built-in if helper, except inverse/fn calls are replaced with false/true
6
+ * https://github.com/handlebars-lang/handlebars.js/blob/19bdace85a8d0bc5ed3a4dec4071cb08c8d003f2/lib/handlebars/helpers/if.js#L9-L20
6
7
  */
8
+ function isEmptyValue(value) {
9
+ if (!value && value !== 0) {
10
+ return true;
11
+ } else if (Array.isArray(value) && value.length === 0) {
12
+ return true;
13
+ } else {
14
+ return false;
15
+ }
16
+ }
17
+
7
18
  const handleConditional = (conditional, options) => {
8
19
  if (_.isFunction(conditional)) {
9
20
  conditional = conditional.call(this);
10
21
  }
11
22
 
23
+ if (conditional instanceof SafeString) {
24
+ conditional = conditional.string;
25
+ }
26
+
12
27
  // Default behavior is to render the positive path if the value is truthy and not empty.
13
28
  // The `includeZero` option may be set to treat the condtional as purely not empty based on the
14
29
  // behavior of isEmpty. Effectively this determines if 0 is handled by the positive path or negative.
15
- if ((!options.hash.includeZero && !conditional) || _.isEmpty(conditional)) {
16
- return true;
17
- } else {
30
+ if ((!options.hash.includeZero && !conditional) || isEmptyValue(conditional)) {
18
31
  return false;
32
+ } else {
33
+ return true;
19
34
  }
20
35
  };
21
36
 
@@ -0,0 +1,68 @@
1
+ // # Products Helper
2
+ // Usage: `{{products}}`, `{{products separator=' - '}}`
3
+ //
4
+ // Returns a string of the products with access to the post.
5
+ // By default, products are separated by commas.
6
+
7
+ const nql = require('@nexes/nql');
8
+ const isString = require('lodash/isString');
9
+ const {SafeString, labs} = require('../services/proxy');
10
+
11
+ function products(options = {}) {
12
+ options = options || {};
13
+ options.hash = options.hash || {};
14
+
15
+ const separator = isString(options.hash.separator) ? options.hash.separator : '';
16
+ let output = '';
17
+
18
+ let productsList = [];
19
+ if (options.data.product) {
20
+ productsList = [options.data.product];
21
+ }
22
+ if (options.data.products) {
23
+ productsList = options.data.products;
24
+ }
25
+ let accessProductsList = [];
26
+
27
+ if (['members', 'paid', 'public'].includes(this.visibility)) {
28
+ accessProductsList = productsList;
29
+ }
30
+
31
+ if (this.visibility === 'filter') {
32
+ const nqlFilter = nql(this.visibility_filter);
33
+ accessProductsList = productsList.filter((product) => {
34
+ return nqlFilter.queryJSON({product: product.slug});
35
+ });
36
+ }
37
+
38
+ if (accessProductsList.length > 0) {
39
+ const productNames = accessProductsList.map(product => product.name);
40
+ if (accessProductsList.length === 1) {
41
+ output = productNames[0] + ' tier';
42
+ } else {
43
+ if (separator) {
44
+ output = productNames.join(separator) + ' tiers';
45
+ } else {
46
+ const firsts = productNames.slice(0, productNames.length - 1);
47
+ const last = productNames[productNames.length - 1];
48
+ output = firsts.join(', ') + ' and ' + last + ' tiers';
49
+ }
50
+ }
51
+ }
52
+
53
+ return new SafeString(output);
54
+ }
55
+
56
+ module.exports = function productsLabsWrapper() {
57
+ let self = this;
58
+ let args = arguments;
59
+
60
+ return labs.enabledHelper({
61
+ flagKey: 'multipleProducts',
62
+ flagName: 'Tiers',
63
+ helperName: 'products',
64
+ helpUrl: 'https://ghost.org/docs/themes/'
65
+ }, () => {
66
+ return products.apply(self, args);
67
+ });
68
+ };
@@ -8,7 +8,7 @@
8
8
  <h2>This post is for subscribers only</h2>
9
9
  {{/has}}
10
10
  {{#has visibility="filter"}}
11
- <h2>This post is for subscribers on a higher tier only</h2>
11
+ <h2>This post is for subscribers on the {{products}} only </h2>
12
12
  {{/has}}
13
13
  {{#if @member}}
14
14
  <a class="gh-btn" data-portal="account/plans" style="color:{{accentColor}}">Upgrade your account</a>
@@ -3,6 +3,7 @@ const config = require('../../../../shared/config');
3
3
  const urlService = require('../../url');
4
4
  const urlUtils = require('../../../../shared/url-utils');
5
5
  const helpers = require('../helpers');
6
+ const labs = require('../../../../shared/labs');
6
7
 
7
8
  /**
8
9
  * @description Email Post Controller.
@@ -11,7 +12,7 @@ const helpers = require('../helpers');
11
12
  * @param {Function} next
12
13
  * @returns {Promise}
13
14
  */
14
- module.exports = function emailPostController(req, res, next) {
15
+ module.exports = [labs.enabledMiddleware('emailOnlyPosts'), function emailPostController(req, res, next) {
15
16
  debug('emailPostController');
16
17
 
17
18
  const api = require('../../proxy').api[res.locals.apiVersion];
@@ -62,4 +63,4 @@ module.exports = function emailPostController(req, res, next) {
62
63
  return renderer(post);
63
64
  })
64
65
  .catch(helpers.handleError(next));
65
- };
66
+ }];
@@ -22,7 +22,7 @@ const getSettingFilePath = (setting) => {
22
22
 
23
23
  /**
24
24
  * Functionally same as loadSettingsSync with exception of loading
25
- * settigs asyncronously. This method is used at new places to read settings
25
+ * settings asynchronously. This method is used at new places to read settings
26
26
  * to prevent blocking the eventloop
27
27
  *
28
28
  * @param {String} setting the requested settings as defined in setting knownSettings
@@ -56,7 +56,7 @@ const loadSettings = async (setting) => {
56
56
  /**
57
57
  * Reads the desired settings YAML file and passes the
58
58
  * file to the YAML parser which then returns a JSON object.
59
- * NOTE: loading happens syncronously
59
+ * NOTE: loading happens synchronously
60
60
  *
61
61
  * @param {String} setting the requested settings as defined in setting knownSettings
62
62
  * @returns {Object} settingsFile
@@ -19,30 +19,34 @@ class BaseSiteMapGenerator {
19
19
  this.nodeTimeLookup = {};
20
20
  this.siteMapContent = null;
21
21
  this.lastModified = 0;
22
+ this.maxNodes = 50000;
22
23
  }
23
24
 
24
25
  generateXmlFromNodes() {
25
- const self = this;
26
-
27
26
  // Get a mapping of node to timestamp
28
- const timedNodes = _.map(this.nodeLookup, function (node, id) {
27
+ let nodesToProcess = _.map(this.nodeLookup, (node, id) => {
29
28
  return {
30
29
  id: id,
31
30
  // Using negative here to sort newest to oldest
32
- ts: -(self.nodeTimeLookup[id] || 0),
31
+ ts: -(this.nodeTimeLookup[id] || 0),
33
32
  node: node
34
33
  };
35
- }, []);
34
+ });
35
+
36
+ // Limit to 50k nodes - this is a quick fix to prevent errors in google console
37
+ if (this.maxNodes) {
38
+ nodesToProcess = nodesToProcess.slice(0, this.maxNodes);
39
+ }
36
40
 
37
41
  // Sort nodes by timestamp
38
- const sortedNodes = _.sortBy(timedNodes, 'ts');
42
+ nodesToProcess = _.sortBy(nodesToProcess, 'ts');
39
43
 
40
44
  // Grab just the nodes
41
- const urlElements = _.map(sortedNodes, 'node');
45
+ nodesToProcess = _.map(nodesToProcess, 'node');
42
46
 
43
47
  const data = {
44
48
  // Concat the elements to the _attr declaration
45
- urlset: [XMLNS_DECLS].concat(urlElements)
49
+ urlset: [XMLNS_DECLS].concat(nodesToProcess)
46
50
  };
47
51
 
48
52
  // Generate full xml
@@ -15,7 +15,7 @@ class SiteMapManager {
15
15
  this.posts = options.posts || this.createPostsGenerator(options);
16
16
  this.users = this.authors = options.authors || this.createUsersGenerator(options);
17
17
  this.tags = options.tags || this.createTagsGenerator(options);
18
- this.index = options.index || this.createIndexGenerator(options);
18
+ this.index = options.index || this.createIndexGenerator();
19
19
 
20
20
  events.on('router.created', (router) => {
21
21
  if (router.name === 'StaticRoutesRouter') {
@@ -12,6 +12,7 @@ const registerAllCoreHelpers = function registerAllCoreHelpers() {
12
12
  registerThemeHelper('cancel_link', coreHelpers.cancel_link);
13
13
  registerThemeHelper('concat', coreHelpers.concat);
14
14
  registerThemeHelper('content', coreHelpers.content);
15
+ registerThemeHelper('products', coreHelpers.products);
15
16
  registerThemeHelper('date', coreHelpers.date);
16
17
  registerThemeHelper('encode', coreHelpers.encode);
17
18
  registerThemeHelper('excerpt', coreHelpers.excerpt);
@@ -5,6 +5,7 @@ const {api} = require('../proxy');
5
5
  const errors = require('@tryghost/errors');
6
6
  const tpl = require('@tryghost/tpl');
7
7
  const settingsCache = require('../../../shared/settings-cache');
8
+ const customThemeSettingsCache = require('../../../shared/custom-theme-settings-cache');
8
9
  const labs = require('../../../shared/labs');
9
10
  const activeTheme = require('./active');
10
11
  const preview = require('./preview');
@@ -114,6 +115,7 @@ async function updateGlobalTemplateOptions(req, res, next) {
114
115
  posts_per_page: activeTheme.get().config('posts_per_page'),
115
116
  image_sizes: activeTheme.get().config('image_sizes')
116
117
  };
118
+ const themeSettingsData = customThemeSettingsCache.getAll();
117
119
  const productData = await getProductAndPricesData();
118
120
  const priceData = calculateLegacyPriceData(productData);
119
121
 
@@ -136,7 +138,8 @@ async function updateGlobalTemplateOptions(req, res, next) {
136
138
  config: themeData,
137
139
  price: priceData,
138
140
  product,
139
- products
141
+ products,
142
+ custom: themeSettingsData
140
143
  }
141
144
  });
142
145
  }
@@ -2,7 +2,10 @@ const models = require('../../models');
2
2
  const i18n = require('../../../shared/i18n');
3
3
  const errors = require('@tryghost/errors');
4
4
  const mega = require('../../services/mega');
5
- const labs = require('../../../shared/labs');
5
+
6
+ const emailPreview = new mega.EmailPreview({
7
+ apiVersion: 'canary'
8
+ });
6
9
 
7
10
  module.exports = {
8
11
  docName: 'email_preview',
@@ -22,34 +25,19 @@ module.exports = {
22
25
  'status'
23
26
  ],
24
27
  permissions: true,
25
- query(frame) {
28
+ async query(frame) {
26
29
  const options = Object.assign(frame.options, {formats: 'html,plaintext', withRelated: ['authors', 'posts_meta']});
27
30
  const data = Object.assign(frame.data, {status: 'all'});
28
- return models.Post.findOne(data, options)
29
- .then((model) => {
30
- if (!model) {
31
- throw new errors.NotFoundError({
32
- message: i18n.t('errors.api.posts.postNotFound')
33
- });
34
- }
35
-
36
- return mega.postEmailSerializer.serialize(model, {isBrowserPreview: true, apiVersion: 'canary'}).then((emailContent) => {
37
- if (labs.isSet('emailCardSegments') && frame.options.memberSegment) {
38
- emailContent = mega.postEmailSerializer.renderEmailForSegment(emailContent, frame.options.memberSegment);
39
- }
40
-
41
- const replacements = mega.postEmailSerializer.parseReplacements(emailContent);
42
31
 
43
- replacements.forEach((replacement) => {
44
- emailContent[replacement.format] = emailContent[replacement.format].replace(
45
- replacement.match,
46
- replacement.fallback || ''
47
- );
48
- });
32
+ const model = await models.Post.findOne(data, options);
49
33
 
50
- return emailContent;
51
- });
34
+ if (!model) {
35
+ throw new errors.NotFoundError({
36
+ message: i18n.t('errors.api.posts.postNotFound')
52
37
  });
38
+ }
39
+
40
+ return emailPreview.generateEmailContent(model, frame.options.memberSegment);
53
41
  }
54
42
  },
55
43
  sendTestEmail: {
@@ -69,21 +57,15 @@ module.exports = {
69
57
  async query(frame) {
70
58
  const options = Object.assign(frame.options, {status: 'all'});
71
59
  let model = await models.Post.findOne(options, {withRelated: ['authors']});
60
+
72
61
  if (!model) {
73
62
  throw new errors.NotFoundError({
74
63
  message: i18n.t('errors.api.posts.postNotFound')
75
64
  });
76
65
  }
66
+
77
67
  const {emails = [], memberSegment} = frame.data;
78
- const response = await mega.mega.sendTestEmail(model, emails, 'canary', memberSegment);
79
- if (response && response[0] && response[0].error) {
80
- throw new errors.EmailError({
81
- statusCode: response[0].error.statusCode,
82
- message: response[0].error.message,
83
- context: response[0].error.originalMessage
84
- });
85
- }
86
- return response;
68
+ return await mega.mega.sendTestEmail(model, emails, 'canary', memberSegment);
87
69
  }
88
70
  }
89
71
  };
@@ -1,6 +1,12 @@
1
1
  const i18n = require('../../../shared/i18n');
2
2
  const errors = require('@tryghost/errors');
3
3
  const models = require('../../models');
4
+ const getIntegrationsServiceInstance = require('../../services/integrations/integrations-service');
5
+
6
+ const integrationsService = getIntegrationsServiceInstance({
7
+ IntegrationModel: models.Integration,
8
+ ApiKeyModel: models.ApiKey
9
+ });
4
10
 
5
11
  module.exports = {
6
12
  docName: 'integrations',
@@ -76,36 +82,7 @@ module.exports = {
76
82
  }
77
83
  },
78
84
  query({data, options}) {
79
- if (options.keyid) {
80
- return models.ApiKey.findOne({id: options.keyid})
81
- .then(async (model) => {
82
- if (!model) {
83
- throw new errors.NotFoundError({
84
- message: i18n.t('errors.api.resource.resourceNotFound', {
85
- resource: 'ApiKey'
86
- })
87
- });
88
- }
89
- try {
90
- await models.ApiKey.refreshSecret(model.toJSON(), Object.assign({}, options, {id: options.keyid}));
91
- return models.Integration.findOne({id: options.id}, {
92
- withRelated: ['api_keys', 'webhooks']
93
- });
94
- } catch (err) {
95
- throw new errors.GhostError({
96
- err: err
97
- });
98
- }
99
- });
100
- }
101
- return models.Integration.edit(data, Object.assign(options, {require: true}))
102
- .catch(models.Integration.NotFoundError, () => {
103
- throw new errors.NotFoundError({
104
- message: i18n.t('errors.api.resource.resourceNotFound', {
105
- resource: 'Integration'
106
- })
107
- });
108
- });
85
+ return integrationsService.edit(data, options);
109
86
  }
110
87
  },
111
88
  add: {
@@ -76,16 +76,15 @@ module.exports = {
76
76
  }
77
77
  },
78
78
  permissions: true,
79
- async query(frame) {
80
- try {
81
- return await models.Label.add(frame.data.labels[0], frame.options);
82
- } catch (error) {
83
- if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
84
- throw new errors.ValidationError({message: i18n.t('errors.api.labels.labelAlreadyExists')});
85
- }
79
+ query(frame) {
80
+ return models.Label.add(frame.data.labels[0], frame.options)
81
+ .catch((error) => {
82
+ if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
83
+ throw new errors.ValidationError({message: i18n.t('errors.api.labels.labelAlreadyExists')});
84
+ }
86
85
 
87
- throw error;
88
- }
86
+ throw error;
87
+ });
89
88
  }
90
89
  },
91
90
 
@@ -124,8 +124,10 @@ module.exports = {
124
124
  }, frame.options);
125
125
  }
126
126
 
127
- if (frame.data.members[0].comped) {
128
- await membersService.api.members.setComplimentarySubscription(member);
127
+ if (!labsService.isSet('multipleProducts')) {
128
+ if (frame.data.members[0].comped) {
129
+ await membersService.api.members.setComplimentarySubscription(member);
130
+ }
129
131
  }
130
132
 
131
133
  if (frame.options.send_email) {
@@ -188,14 +190,16 @@ module.exports = {
188
190
 
189
191
  const hasCompedSubscription = !!member.related('stripeSubscriptions').find(sub => sub.get('plan_nickname') === 'Complimentary' && sub.get('status') === 'active');
190
192
 
191
- if (typeof frame.data.members[0].comped === 'boolean') {
192
- if (frame.data.members[0].comped && !hasCompedSubscription) {
193
- await membersService.api.members.setComplimentarySubscription(member);
194
- } else if (!(frame.data.members[0].comped) && hasCompedSubscription) {
195
- await membersService.api.members.cancelComplimentarySubscription(member);
196
- }
193
+ if (!labsService.isSet('multipleProducts')) {
194
+ if (typeof frame.data.members[0].comped === 'boolean') {
195
+ if (frame.data.members[0].comped && !hasCompedSubscription) {
196
+ await membersService.api.members.setComplimentarySubscription(member);
197
+ } else if (!(frame.data.members[0].comped) && hasCompedSubscription) {
198
+ await membersService.api.members.cancelComplimentarySubscription(member);
199
+ }
197
200
 
198
- await member.load(['stripeSubscriptions', 'products', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct']);
201
+ await member.load(['stripeSubscriptions', 'products', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct']);
202
+ }
199
203
  }
200
204
 
201
205
  await member.load(['stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct']);
@@ -1,11 +1,6 @@
1
- const _ = require('lodash');
2
- const moment = require('moment');
3
- const config = require('../../../shared/config');
4
1
  const models = require('../../models');
5
- const urlUtils = require('../../../shared/url-utils');
6
- const i18n = require('../../../shared/i18n');
7
- const errors = require('@tryghost/errors');
8
- const api = require('./index');
2
+
3
+ const postSchedulingService = require('../../services/posts/post-scheduling-service')('canary');
9
4
 
10
5
  module.exports = {
11
6
  docName: 'schedules',
@@ -32,11 +27,8 @@ module.exports = {
32
27
  permissions: {
33
28
  docName: 'posts'
34
29
  },
35
- query(frame) {
36
- let resource;
30
+ async query(frame) {
37
31
  const resourceType = frame.options.resource;
38
- const publishAPostBySchedulerToleranceInMinutes = config.get('times').publishAPostBySchedulerToleranceInMinutes;
39
-
40
32
  const options = {
41
33
  status: 'scheduled',
42
34
  id: frame.options.id,
@@ -45,53 +37,13 @@ module.exports = {
45
37
  }
46
38
  };
47
39
 
48
- return api[resourceType].read({id: frame.options.id}, options)
49
- .then((result) => {
50
- resource = result[resourceType][0];
51
- const publishedAtMoment = moment(resource.published_at);
52
-
53
- if (publishedAtMoment.diff(moment(), 'minutes') > publishAPostBySchedulerToleranceInMinutes) {
54
- return Promise.reject(new errors.NotFoundError({message: i18n.t('errors.api.job.notFound')}));
55
- }
56
-
57
- if (publishedAtMoment.diff(moment(), 'minutes') < publishAPostBySchedulerToleranceInMinutes * -1 && frame.data.force !== true) {
58
- return Promise.reject(new errors.NotFoundError({message: i18n.t('errors.api.job.publishInThePast')}));
59
- }
40
+ const {scheduledResource, preScheduledResource} = await postSchedulingService.publish(resourceType, frame.options.id, frame.data.force, options);
41
+ const cacheInvalidate = postSchedulingService.handleCacheInvalidation(scheduledResource, preScheduledResource);
42
+ this.headers.cacheInvalidate = cacheInvalidate;
60
43
 
61
- const editedResource = {};
62
- editedResource[resourceType] = [{
63
- status: 'published',
64
- updated_at: moment(resource.updated_at).toISOString(true)
65
- }];
66
-
67
- return api[resourceType].edit(
68
- editedResource,
69
- _.pick(options, ['context', 'id', 'transacting', 'forUpdate'])
70
- );
71
- })
72
- .then((result) => {
73
- const scheduledResource = result[resourceType][0];
74
-
75
- if (
76
- (scheduledResource.status === 'published' && resource.status !== 'published') ||
77
- (scheduledResource.status === 'draft' && resource.status === 'published')
78
- ) {
79
- this.headers.cacheInvalidate = true;
80
- } else if (
81
- (scheduledResource.status === 'draft' && resource.status !== 'published') ||
82
- (scheduledResource.status === 'scheduled' && resource.status !== 'scheduled')
83
- ) {
84
- this.headers.cacheInvalidate = {
85
- value: urlUtils.urlFor({
86
- relativeUrl: urlUtils.urlJoin('/p', scheduledResource.uuid, '/')
87
- })
88
- };
89
- } else {
90
- this.headers.cacheInvalidate = false;
91
- }
92
-
93
- return result;
94
- });
44
+ const response = {};
45
+ response[resourceType] = [scheduledResource];
46
+ return response;
95
47
  }
96
48
  },
97
49