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.
- package/content/themes/casper/package.json +2 -3
- package/content/themes/casper/partials/post-card.hbs +1 -1
- package/core/built/assets/ghost-dark-470c1ef06b10e5c40ad05f3a642eaaea.css +1 -0
- package/core/built/assets/{ghost.min-20096eef632760c3a2906e243adbd24b.js → ghost.min-1e7dce606e92a03207d15ae7eb3d3c23.js} +411 -323
- package/core/built/assets/ghost.min-d0c17e8314b5583c0df5d05fab3c051c.css +1 -0
- package/core/built/assets/{vendor.min-21f79c68a284acb1b70039f3f63e5507.js → vendor.min-fe2c9b1235b4119b5406b788db2db434.js} +88 -82
- package/core/frontend/apps/amp/lib/helpers/amp_analytics.js +1 -1
- package/core/frontend/apps/amp/lib/helpers/amp_components.js +1 -1
- package/core/frontend/apps/amp/lib/helpers/amp_content.js +1 -1
- package/core/frontend/apps/amp/lib/helpers/amp_style.js +1 -1
- package/core/frontend/apps/amp/lib/router.js +6 -5
- package/core/frontend/apps/private-blogging/lib/helpers/input_password.js +1 -1
- package/core/frontend/apps/private-blogging/lib/router.js +2 -2
- package/core/frontend/helpers/asset.js +1 -1
- package/core/frontend/helpers/author.js +1 -1
- package/core/frontend/helpers/authors.js +1 -1
- package/core/frontend/helpers/body_class.js +1 -1
- package/core/frontend/helpers/cancel_link.js +1 -1
- package/core/frontend/helpers/concat.js +1 -1
- package/core/frontend/helpers/content.js +1 -1
- package/core/frontend/helpers/date.js +1 -1
- package/core/frontend/helpers/encode.js +1 -1
- package/core/frontend/helpers/excerpt.js +1 -1
- package/core/frontend/helpers/facebook_url.js +1 -1
- package/core/frontend/helpers/foreach.js +2 -2
- package/core/frontend/helpers/get.js +1 -1
- package/core/frontend/helpers/ghost_foot.js +1 -1
- package/core/frontend/helpers/ghost_head.js +1 -1
- package/core/frontend/helpers/lang.js +1 -1
- package/core/frontend/helpers/link.js +1 -1
- package/core/frontend/helpers/link_class.js +1 -1
- package/core/frontend/helpers/match.js +1 -1
- package/core/frontend/helpers/navigation.js +1 -1
- package/core/frontend/helpers/pagination.js +1 -1
- package/core/frontend/helpers/plural.js +1 -1
- package/core/frontend/helpers/post_class.js +1 -1
- package/core/frontend/helpers/prev_post.js +6 -5
- package/core/frontend/helpers/products.js +1 -1
- package/core/frontend/helpers/reading_time.js +2 -2
- package/core/frontend/helpers/t.js +1 -1
- package/core/frontend/helpers/tags.js +1 -1
- package/core/frontend/helpers/tiers.js +1 -1
- package/core/frontend/helpers/title.js +1 -1
- package/core/frontend/helpers/twitter_url.js +1 -1
- package/core/frontend/helpers/url.js +1 -1
- package/core/frontend/meta/url.js +4 -4
- package/core/{server/data/schema → frontend/services/data}/checks.js +4 -4
- package/core/frontend/services/{routing/helpers → data}/entry-lookup.js +3 -3
- package/core/frontend/services/{routing/helpers → data}/fetch-data.js +3 -3
- package/core/frontend/services/data/index.js +5 -0
- package/core/frontend/services/{rendering.js → handlebars.js} +2 -1
- package/core/frontend/services/helpers/handlebars.js +1 -1
- package/core/frontend/services/proxy.js +2 -4
- package/core/frontend/services/{routing/helpers → rendering}/context.js +0 -0
- package/core/frontend/services/{routing/helpers → rendering}/error.js +0 -0
- package/core/frontend/services/{routing/helpers → rendering}/format-response.js +1 -1
- package/core/frontend/services/{routing/helpers → rendering}/index.js +0 -8
- package/core/frontend/services/{routing/helpers → rendering}/render-entries.js +1 -1
- package/core/frontend/services/{routing/helpers → rendering}/render-entry.js +1 -1
- package/core/frontend/services/{routing/helpers → rendering}/renderer.js +1 -1
- package/core/frontend/services/{routing/helpers → rendering}/secure.js +0 -0
- package/core/frontend/services/{routing/helpers → rendering}/templates.js +2 -2
- package/core/frontend/services/routing/CollectionRouter.js +1 -1
- package/core/frontend/services/routing/controllers/channel.js +9 -9
- package/core/frontend/services/routing/controllers/collection.js +9 -9
- package/core/frontend/services/routing/controllers/email-post.js +5 -6
- package/core/frontend/services/routing/controllers/entry.js +6 -6
- package/core/frontend/services/routing/controllers/preview.js +5 -6
- package/core/frontend/services/routing/controllers/rss.js +4 -3
- package/core/frontend/services/routing/controllers/static.js +5 -5
- package/core/frontend/services/routing/controllers/unsubscribe.js +2 -2
- package/core/frontend/services/routing/index.js +0 -4
- package/core/frontend/web/middleware/error-handler.js +2 -2
- package/core/server/api/canary/authentication.js +2 -2
- package/core/server/api/canary/posts.js +1 -0
- package/core/server/api/canary/stats.js +9 -0
- package/core/server/api/canary/utils/serializers/output/members.js +8 -0
- package/core/server/api/canary/utils/validators/input/index.js +6 -0
- package/core/server/api/shared/http.js +52 -51
- package/core/server/data/exporter/table-lists.js +1 -0
- package/core/server/data/migrations/utils.js +33 -1
- package/core/server/data/migrations/versions/4.42/2022-03-21-17-17-add.js +5 -0
- package/core/server/data/migrations/versions/4.43/2022-03-28-19-26-recreate-newsletter-table.js +29 -0
- package/core/server/data/migrations/versions/4.43/2022-03-29-14-45-add-members-newsletters-table.js +7 -0
- package/core/server/data/migrations/versions/4.43/2022-04-01-10-13-add-post-newsletter-relation.js +108 -0
- package/core/server/data/migrations/versions/4.43/2022-04-06-09-47-add-type-column-to-paid-subscription-events.js +7 -0
- package/core/server/data/migrations/versions/4.43/2022-04-06-14-56-add-email-newsletter-relation.js +8 -0
- package/core/server/data/migrations/versions/4.43/2022-04-08-10-45-add-subscription-id-to-mrr-events.js +7 -0
- package/core/server/data/migrations/versions/4.44/2022-04-06-15-22-populate-type-column-for-paid-subscription-events.js +21 -0
- package/core/server/data/migrations/versions/4.44/2022-04-08-11-54-add-cancelled-events.js +51 -0
- package/core/server/data/migrations/versions/4.44/2022-04-11-08-24-add-newsletter-permissions.js +33 -0
- package/core/server/data/migrations/versions/4.44/2022-04-11-10-54-add-mrr-to-subscriptions.js +8 -0
- package/core/server/data/migrations/versions/4.44/2022-04-12-07-33-fill-mrr.js +29 -0
- package/core/server/data/migrations/versions/4.44/2022-04-13-12-00-remove-newsletter-sender-name-not-null-constraint.js +33 -0
- package/core/server/data/migrations/versions/4.44/2022-04-15-07-53-add-offer-id-to-subscriptions.js +9 -0
- package/core/server/data/schema/commands.js +6 -1
- package/core/server/data/schema/fixtures/fixtures.json +26 -1
- package/core/server/data/schema/index.js +0 -1
- package/core/server/data/schema/schema.js +36 -16
- package/core/server/models/base/bookshelf.js +1 -1
- package/core/server/models/base/plugins/crud.js +8 -0
- package/core/server/models/member.js +21 -1
- package/core/server/models/newsletter.js +42 -1
- package/core/server/models/post.js +12 -2
- package/core/server/models/stripe-customer-subscription.js +4 -0
- package/core/server/services/auth/setup.js +21 -8
- package/core/server/services/mega/mega.js +3 -1
- package/core/server/services/members/api.js +3 -1
- package/core/server/services/members/middleware.js +13 -3
- package/core/server/services/members/service.js +3 -11
- package/core/server/services/members/utils.js +13 -1
- package/core/server/services/newsletters/index.js +10 -0
- package/core/server/services/newsletters/service.js +24 -0
- package/core/server/services/posts/posts-service.js +20 -1
- package/core/server/services/slack.js +11 -3
- package/core/server/services/stats/lib/members-stats-service.js +30 -34
- package/core/server/services/stats/lib/mrr-stats-service.js +154 -0
- package/core/server/services/stats/service.js +3 -1
- package/core/server/services/stripe/service.js +1 -0
- package/core/server/web/admin/views/default-prod.html +4 -4
- package/core/server/web/admin/views/default.html +4 -4
- package/core/server/web/api/canary/admin/routes.js +1 -0
- package/core/shared/config/defaults.json +2 -2
- package/package.json +39 -39
- package/yarn.lock +410 -369
- package/content/themes/casper/assets/css/csscomb.json +0 -240
- package/core/built/assets/ghost-dark-a93afb20027060d760ac6d78f115a76f.css +0 -1
- package/core/built/assets/ghost.min-ce35ef1b76d9a943ab912c076773b132.css +0 -1
|
@@ -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
|
);
|
|
@@ -674,6 +674,13 @@ Post = ghostBookshelf.Model.extend({
|
|
|
674
674
|
}
|
|
675
675
|
}
|
|
676
676
|
|
|
677
|
+
// newsletter_id is read-only and should only be set using a query param when publishing/scheduling
|
|
678
|
+
if (options.newsletter_id
|
|
679
|
+
&& this.hasChanged('status')
|
|
680
|
+
&& (newStatus === 'published' || newStatus === 'scheduled')) {
|
|
681
|
+
this.set('newsletter_id', options.newsletter_id);
|
|
682
|
+
}
|
|
683
|
+
|
|
677
684
|
// email_recipient_filter is read-only and should only be set using a query param when publishing/scheduling
|
|
678
685
|
if (options.email_recipient_filter
|
|
679
686
|
&& (options.email_recipient_filter !== 'none')
|
|
@@ -878,6 +885,9 @@ Post = ghostBookshelf.Model.extend({
|
|
|
878
885
|
// CASE: never expose the revisions
|
|
879
886
|
delete attrs.mobiledoc_revisions;
|
|
880
887
|
|
|
888
|
+
// CASE: hide the newsletter_id for now
|
|
889
|
+
delete attrs.newsletter_id;
|
|
890
|
+
|
|
881
891
|
// If the current column settings allow it...
|
|
882
892
|
if (!options.columns || (options.columns && options.columns.indexOf('primary_tag') > -1)) {
|
|
883
893
|
// ... attach a computed property of primary_tag which is the first tag if it is public, else null
|
|
@@ -1016,7 +1026,7 @@ Post = ghostBookshelf.Model.extend({
|
|
|
1016
1026
|
findPage: ['status'],
|
|
1017
1027
|
findAll: ['columns', 'filter'],
|
|
1018
1028
|
destroy: ['destroyAll', 'destroyBy'],
|
|
1019
|
-
edit: ['filter', 'email_recipient_filter', 'force_rerender']
|
|
1029
|
+
edit: ['filter', 'email_recipient_filter', 'force_rerender', 'newsletter_id']
|
|
1020
1030
|
};
|
|
1021
1031
|
|
|
1022
1032
|
// The post model additionally supports having a formats option
|
|
@@ -4,6 +4,10 @@ const _ = require('lodash');
|
|
|
4
4
|
const StripeCustomerSubscription = ghostBookshelf.Model.extend({
|
|
5
5
|
tableName: 'members_stripe_customers_subscriptions',
|
|
6
6
|
|
|
7
|
+
defaults: {
|
|
8
|
+
mrr: 0
|
|
9
|
+
},
|
|
10
|
+
|
|
7
11
|
customer() {
|
|
8
12
|
return this.belongsTo('MemberStripeCustomer', 'customer_id', 'customer_id');
|
|
9
13
|
},
|
|
@@ -107,7 +107,9 @@ async function doSettings(data, settingsAPI) {
|
|
|
107
107
|
return user;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
// Update names for default product and newsletter to site title
|
|
111
|
+
async function doProductAndNewsletter(data, api) {
|
|
112
|
+
const {products: productsAPI, newsletters: newslettersAPI} = api;
|
|
111
113
|
const context = {context: {user: data.user.id}};
|
|
112
114
|
const user = data.user;
|
|
113
115
|
const blogTitle = data.userData.blogTitle;
|
|
@@ -116,15 +118,23 @@ async function doProduct(data, productsAPI) {
|
|
|
116
118
|
return user;
|
|
117
119
|
}
|
|
118
120
|
try {
|
|
119
|
-
const
|
|
121
|
+
const productPage = await productsAPI.browse({limit: 'all'});
|
|
122
|
+
const newsletterPage = await newslettersAPI.browse({limit: 'all'});
|
|
120
123
|
|
|
121
|
-
const
|
|
124
|
+
const defaultProduct = productPage.products.find(p => p.slug === 'default-product');
|
|
125
|
+
const defaultNewsletter = newsletterPage.newsletters.find(p => p.slug === 'default-newsletter');
|
|
122
126
|
|
|
123
|
-
if (
|
|
124
|
-
|
|
127
|
+
if (defaultProduct) {
|
|
128
|
+
await productsAPI.edit({products: [{
|
|
129
|
+
name: blogTitle.trim()
|
|
130
|
+
}]}, {context: context.context, id: defaultProduct.id});
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
|
|
133
|
+
if (defaultNewsletter) {
|
|
134
|
+
await newslettersAPI.edit({newsletters: [{
|
|
135
|
+
name: blogTitle.trim()
|
|
136
|
+
}]}, {context: context.context, id: defaultNewsletter.id});
|
|
137
|
+
}
|
|
128
138
|
} catch (e) {
|
|
129
139
|
return data;
|
|
130
140
|
}
|
|
@@ -142,7 +152,10 @@ async function doFixtures(data) {
|
|
|
142
152
|
mobiledoc = mobiledoc.replace(/{{date}}/, date);
|
|
143
153
|
|
|
144
154
|
const post = await models.Post.findOne({slug: key});
|
|
145
|
-
|
|
155
|
+
|
|
156
|
+
if (post) {
|
|
157
|
+
await models.Post.edit({mobiledoc}, {id: post.id});
|
|
158
|
+
}
|
|
146
159
|
});
|
|
147
160
|
|
|
148
161
|
return data;
|
|
@@ -218,7 +231,7 @@ module.exports = {
|
|
|
218
231
|
assertSetupCompleted: assertSetupCompleted,
|
|
219
232
|
setupUser: setupUser,
|
|
220
233
|
doSettings: doSettings,
|
|
221
|
-
|
|
234
|
+
doProductAndNewsletter: doProductAndNewsletter,
|
|
222
235
|
installTheme: installTheme,
|
|
223
236
|
doFixtures: doFixtures,
|
|
224
237
|
sendWelcomeEmail: sendWelcomeEmail
|
|
@@ -159,6 +159,7 @@ const transformEmailRecipientFilter = (emailRecipientFilter, {errorProperty = 'e
|
|
|
159
159
|
* @param {object} postModel Post Model Object
|
|
160
160
|
* @param {object} options
|
|
161
161
|
* @param {ValidAPIVersion} options.apiVersion - api version to be used when serializing email data
|
|
162
|
+
* @param {string} options.newsletter_id - the newsletter_id to send the email to
|
|
162
163
|
*/
|
|
163
164
|
|
|
164
165
|
const addEmail = async (postModel, options) => {
|
|
@@ -211,7 +212,8 @@ const addEmail = async (postModel, options) => {
|
|
|
211
212
|
plaintext: emailData.plaintext,
|
|
212
213
|
submitted_at: moment().toDate(),
|
|
213
214
|
track_opens: !!settingsCache.get('email_track_opens'),
|
|
214
|
-
recipient_filter: emailRecipientFilter
|
|
215
|
+
recipient_filter: emailRecipientFilter,
|
|
216
|
+
newsletter_id: options.newsletter_id
|
|
215
217
|
}, knexOptions);
|
|
216
218
|
} else {
|
|
217
219
|
return existing;
|
|
@@ -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
|
}
|
|
@@ -58,17 +58,9 @@ const membersImporter = new MembersCSVImporter({
|
|
|
58
58
|
|
|
59
59
|
const processImport = async (options) => {
|
|
60
60
|
const result = await membersImporter.process(options);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const importThreshold = await verificationTrigger.getImportThreshold();
|
|
65
|
-
if (importSize > importThreshold) {
|
|
66
|
-
await verificationTrigger.startVerificationProcess({
|
|
67
|
-
amountImported: importSize,
|
|
68
|
-
throwOnTrigger: true,
|
|
69
|
-
source: 'import'
|
|
70
|
-
});
|
|
71
|
-
}
|
|
61
|
+
|
|
62
|
+
// Check whether all imports in last 30 days > threshold
|
|
63
|
+
await verificationTrigger.testImportThreshold();
|
|
72
64
|
|
|
73
65
|
return result;
|
|
74
66
|
};
|
|
@@ -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
|
-
|
|
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
|
+
|
|
@@ -4,7 +4,8 @@ const tpl = require('@tryghost/tpl');
|
|
|
4
4
|
|
|
5
5
|
const messages = {
|
|
6
6
|
invalidEmailRecipientFilter: 'Invalid filter in email_recipient_filter param.',
|
|
7
|
-
invalidVisibilityFilter: 'Invalid visibility filter.'
|
|
7
|
+
invalidVisibilityFilter: 'Invalid visibility filter.',
|
|
8
|
+
invalidNewsletterId: 'The newsletter_id parameter doesn\'t match any active newsletter.'
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
class PostsService {
|
|
@@ -19,6 +20,16 @@ class PostsService {
|
|
|
19
20
|
async editPost(frame) {
|
|
20
21
|
let model;
|
|
21
22
|
|
|
23
|
+
// Make sure the newsletter_id is matching an active newsletter
|
|
24
|
+
if (frame.options.newsletter_id) {
|
|
25
|
+
const newsletter = await this.models.Newsletter.findOne({id: frame.options.newsletter_id, status: 'active'}, {transacting: frame.options.transacting});
|
|
26
|
+
if (!newsletter) {
|
|
27
|
+
throw new BadRequestError({
|
|
28
|
+
message: messages.invalidNewsletterId
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
if (!frame.options.email_recipient_filter && frame.options.send_email_when_published) {
|
|
23
34
|
await this.models.Base.transaction(async (transacting) => {
|
|
24
35
|
const options = {
|
|
@@ -65,6 +76,14 @@ class PostsService {
|
|
|
65
76
|
const sendEmail = model.wasChanged() && this.shouldSendEmail(model.get('status'), model.previous('status'));
|
|
66
77
|
|
|
67
78
|
if (sendEmail) {
|
|
79
|
+
// Set the newsletter_id if it isn't passed to the API
|
|
80
|
+
if (!frame.options.newsletter_id) {
|
|
81
|
+
const newsletters = await this.models.Newsletter.findPage({status: 'active', limit: 1, columns: ['id']}, {transacting: frame.options.transacting});
|
|
82
|
+
if (newsletters.data.length > 0) {
|
|
83
|
+
frame.options.newsletter_id = newsletters.data[0].id;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
let postEmail = model.relations.email;
|
|
69
88
|
|
|
70
89
|
if (!postEmail) {
|
|
@@ -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 (
|
|
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 (
|
|
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.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const
|
|
1
|
+
const moment = require('moment');
|
|
2
2
|
|
|
3
3
|
class MembersStatsService {
|
|
4
4
|
constructor({db}) {
|
|
@@ -28,8 +28,8 @@ class MembersStatsService {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Get the member deltas by status for all days
|
|
32
|
-
* @returns {Promise<MemberStatusDelta[]>} The deltas of paid, free and comped users per day, sorted
|
|
31
|
+
* Get the member deltas by status for all days, sorted ascending
|
|
32
|
+
* @returns {Promise<MemberStatusDelta[]>} The deltas of paid, free and comped users per day, sorted ascending
|
|
33
33
|
*/
|
|
34
34
|
async fetchAllStatusDeltas() {
|
|
35
35
|
const knex = this.db.knex;
|
|
@@ -54,7 +54,7 @@ class MembersStatsService {
|
|
|
54
54
|
ELSE 0 END
|
|
55
55
|
) as free_delta`))
|
|
56
56
|
.groupByRaw('DATE(created_at)')
|
|
57
|
-
.orderByRaw('DATE(created_at)
|
|
57
|
+
.orderByRaw('DATE(created_at)');
|
|
58
58
|
return rows;
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -69,22 +69,26 @@ class MembersStatsService {
|
|
|
69
69
|
const totals = await this.getCount();
|
|
70
70
|
let {paid, free, comped} = totals;
|
|
71
71
|
|
|
72
|
-
// Get today in UTC (default timezone
|
|
73
|
-
const today =
|
|
72
|
+
// Get today in UTC (default timezone)
|
|
73
|
+
const today = moment().format('YYYY-MM-DD');
|
|
74
74
|
|
|
75
75
|
const cumulativeResults = [];
|
|
76
|
-
|
|
76
|
+
|
|
77
|
+
// Loop in reverse order (needed to have correct sorted result)
|
|
78
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
79
|
+
const row = rows[i];
|
|
80
|
+
|
|
77
81
|
// Convert JSDates to YYYY-MM-DD (in UTC)
|
|
78
|
-
const date =
|
|
82
|
+
const date = moment(row.date).format('YYYY-MM-DD');
|
|
79
83
|
if (date > today) {
|
|
80
84
|
// Skip results that are in the future (fix for invalid events)
|
|
81
85
|
continue;
|
|
82
86
|
}
|
|
83
87
|
cumulativeResults.unshift({
|
|
84
88
|
date,
|
|
85
|
-
paid,
|
|
86
|
-
free,
|
|
87
|
-
comped,
|
|
89
|
+
paid: Math.max(0, paid),
|
|
90
|
+
free: Math.max(0, free),
|
|
91
|
+
comped: Math.max(0, comped),
|
|
88
92
|
|
|
89
93
|
// Deltas
|
|
90
94
|
paid_subscribed: row.paid_subscribed,
|
|
@@ -92,36 +96,28 @@ class MembersStatsService {
|
|
|
92
96
|
});
|
|
93
97
|
|
|
94
98
|
// Update current counts
|
|
95
|
-
paid
|
|
96
|
-
free
|
|
97
|
-
comped
|
|
99
|
+
paid -= row.paid_subscribed - row.paid_canceled;
|
|
100
|
+
free -= row.free_delta;
|
|
101
|
+
comped -= row.comped_delta;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
cumulativeResults.push({
|
|
103
|
-
date: today,
|
|
104
|
-
paid,
|
|
105
|
-
free,
|
|
106
|
-
comped,
|
|
104
|
+
// Now also add the oldest day we have left over (this one will be zero, which is also needed as a data point for graphs)
|
|
105
|
+
const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
cumulativeResults.unshift({
|
|
108
|
+
date: oldestDate,
|
|
109
|
+
paid: Math.max(0, paid),
|
|
110
|
+
free: Math.max(0, free),
|
|
111
|
+
comped: Math.max(0, comped),
|
|
112
|
+
|
|
113
|
+
// Deltas
|
|
114
|
+
paid_subscribed: 0,
|
|
115
|
+
paid_canceled: 0
|
|
116
|
+
});
|
|
113
117
|
|
|
114
118
|
return {
|
|
115
119
|
data: cumulativeResults,
|
|
116
120
|
meta: {
|
|
117
|
-
pagination: {
|
|
118
|
-
page: 1,
|
|
119
|
-
limit: 'all',
|
|
120
|
-
pages: 1,
|
|
121
|
-
total: cumulativeResults.length,
|
|
122
|
-
next: null,
|
|
123
|
-
prev: null
|
|
124
|
-
},
|
|
125
121
|
totals
|
|
126
122
|
}
|
|
127
123
|
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
const moment = require('moment');
|
|
2
|
+
|
|
3
|
+
class MrrStatsService {
|
|
4
|
+
constructor({db}) {
|
|
5
|
+
this.db = db;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the current total MRR, grouped by currency (ascending order)
|
|
10
|
+
* @returns {Promise<MrrByCurrency[]>}
|
|
11
|
+
*/
|
|
12
|
+
async getCurrentMrr() {
|
|
13
|
+
const knex = this.db.knex;
|
|
14
|
+
const rows = await knex('members_stripe_customers_subscriptions')
|
|
15
|
+
.select(knex.raw(`plan_currency as currency`))
|
|
16
|
+
.select(knex.raw(`SUM(
|
|
17
|
+
CASE WHEN plan_interval = 'year' THEN
|
|
18
|
+
FLOOR(plan_amount / 12)
|
|
19
|
+
ELSE
|
|
20
|
+
plan_amount
|
|
21
|
+
END
|
|
22
|
+
) AS mrr`))
|
|
23
|
+
.whereIn('status', ['active', 'unpaid', 'past_due'])
|
|
24
|
+
.where('cancel_at_period_end', 0)
|
|
25
|
+
.groupBy('plan_currency')
|
|
26
|
+
.orderBy('currency');
|
|
27
|
+
|
|
28
|
+
if (rows.length === 0) {
|
|
29
|
+
// Add a USD placeholder to always have at least one currency
|
|
30
|
+
rows.push({
|
|
31
|
+
currency: 'usd',
|
|
32
|
+
mrr: 0
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the MRR deltas for all days (from old to new), grouped by currency (ascending alphabetically)
|
|
41
|
+
* @returns {Promise<MrrDelta[]>} The deltas sorted from new to old
|
|
42
|
+
*/
|
|
43
|
+
async fetchAllDeltas() {
|
|
44
|
+
const knex = this.db.knex;
|
|
45
|
+
const rows = await knex('members_paid_subscription_events')
|
|
46
|
+
.select('currency')
|
|
47
|
+
// In SQLite, DATE(created_at) would map to a string value, while DATE(created_at) would map to a JSDate object in MySQL
|
|
48
|
+
// That is why we need the cast here (to have some consistency)
|
|
49
|
+
.select(knex.raw('CAST(DATE(created_at) as CHAR) as date'))
|
|
50
|
+
.select(knex.raw(`SUM(mrr_delta) as delta`))
|
|
51
|
+
.groupByRaw('CAST(DATE(created_at) as CHAR), currency')
|
|
52
|
+
.orderByRaw('CAST(DATE(created_at) as CHAR), currency');
|
|
53
|
+
return rows;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns a list of the MRR history for each day and currency, including the current MRR per currency as meta data.
|
|
58
|
+
* The respons is in ascending date order, and currencies for the same date are always in ascending order.
|
|
59
|
+
* @returns {Promise<MrrHistory>}
|
|
60
|
+
*/
|
|
61
|
+
async getHistory() {
|
|
62
|
+
// Fetch current total amounts and start counting from there
|
|
63
|
+
const totals = await this.getCurrentMrr();
|
|
64
|
+
|
|
65
|
+
const rows = await this.fetchAllDeltas();
|
|
66
|
+
|
|
67
|
+
// Get today in UTC (default timezone)
|
|
68
|
+
const today = moment().format('YYYY-MM-DD');
|
|
69
|
+
|
|
70
|
+
const results = [];
|
|
71
|
+
|
|
72
|
+
// Create a map of the totals by currency for fast lookup and editing
|
|
73
|
+
const currentTotals = {};
|
|
74
|
+
for (const total of totals) {
|
|
75
|
+
currentTotals[total.currency] = total.mrr;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Loop in reverse order (needed to have correct sorted result)
|
|
79
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
80
|
+
const row = rows[i];
|
|
81
|
+
|
|
82
|
+
if (currentTotals[row.currency] === undefined) {
|
|
83
|
+
// Skip unexpected currencies that are not in the totals
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Convert JSDates to YYYY-MM-DD (in UTC)
|
|
88
|
+
const date = moment(row.date).format('YYYY-MM-DD');
|
|
89
|
+
|
|
90
|
+
if (date > today) {
|
|
91
|
+
// Skip results that are in the future for some reason
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
results.unshift({
|
|
96
|
+
date,
|
|
97
|
+
mrr: Math.max(0, currentTotals[row.currency]),
|
|
98
|
+
currency: row.currency
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
currentTotals[row.currency] -= row.delta;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Now also add the oldest days we have left over and do not have deltas
|
|
105
|
+
const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
|
|
106
|
+
|
|
107
|
+
// Note that we also need to loop the totals in reverse order because we need to unshift
|
|
108
|
+
for (let i = totals.length - 1; i >= 0; i -= 1) {
|
|
109
|
+
const total = totals[i];
|
|
110
|
+
results.unshift({
|
|
111
|
+
date: oldestDate,
|
|
112
|
+
mrr: Math.max(0, currentTotals[total.currency]),
|
|
113
|
+
currency: total.currency
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
data: results,
|
|
119
|
+
meta: {
|
|
120
|
+
totals
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = MrrStatsService;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @typedef MrrByCurrency
|
|
130
|
+
* @type {Object}
|
|
131
|
+
* @property {number} mrr
|
|
132
|
+
* @property {string} currency
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @typedef MrrDelta
|
|
137
|
+
* @type {Object}
|
|
138
|
+
* @property {Date} date
|
|
139
|
+
* @property {string} currency
|
|
140
|
+
* @property {number} delta MRR change on this day
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @typedef {Object} MrrRecord
|
|
145
|
+
* @property {string} date In YYYY-MM-DD format
|
|
146
|
+
* @property {string} currency
|
|
147
|
+
* @property {number} mrr MRR on this day
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @typedef {Object} MrrHistory
|
|
152
|
+
* @property {MrrRecord[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
|
|
153
|
+
* @property {Object} meta
|
|
154
|
+
*/
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const db = require('../../data/db');
|
|
2
2
|
const MemberStatsService = require('./lib/members-stats-service');
|
|
3
|
+
const MrrStatsService = require('./lib/mrr-stats-service');
|
|
3
4
|
|
|
4
5
|
module.exports = {
|
|
5
|
-
members: new MemberStatsService({db})
|
|
6
|
+
members: new MemberStatsService({db}),
|
|
7
|
+
mrr: new MrrStatsService({db})
|
|
6
8
|
};
|
|
@@ -12,6 +12,7 @@ const {getConfig} = require('./config');
|
|
|
12
12
|
async function configureApi() {
|
|
13
13
|
const cfg = getConfig(settings, config, urlUtils);
|
|
14
14
|
if (cfg) {
|
|
15
|
+
cfg.testEnv = process.env.NODE_ENV.startsWith('test');
|
|
15
16
|
await module.exports.configure(cfg);
|
|
16
17
|
return true;
|
|
17
18
|
}
|