ghost 5.116.2 → 5.118.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 (131) hide show
  1. package/components/{tryghost-api-framework-5.116.2.tgz → tryghost-api-framework-5.118.0.tgz} +0 -0
  2. package/components/tryghost-constants-5.118.0.tgz +0 -0
  3. package/components/tryghost-custom-fonts-5.118.0.tgz +0 -0
  4. package/components/tryghost-custom-theme-settings-service-5.118.0.tgz +0 -0
  5. package/components/tryghost-domain-events-5.118.0.tgz +0 -0
  6. package/components/tryghost-donations-5.118.0.tgz +0 -0
  7. package/components/tryghost-email-addresses-5.118.0.tgz +0 -0
  8. package/components/{tryghost-email-service-5.116.2.tgz → tryghost-email-service-5.118.0.tgz} +0 -0
  9. package/components/tryghost-email-suppression-list-5.118.0.tgz +0 -0
  10. package/components/tryghost-html-to-plaintext-5.118.0.tgz +0 -0
  11. package/components/tryghost-i18n-5.118.0.tgz +0 -0
  12. package/components/{tryghost-job-manager-5.116.2.tgz → tryghost-job-manager-5.118.0.tgz} +0 -0
  13. package/components/tryghost-link-replacer-5.118.0.tgz +0 -0
  14. package/components/{tryghost-magic-link-5.116.2.tgz → tryghost-magic-link-5.118.0.tgz} +0 -0
  15. package/components/{tryghost-member-attribution-5.116.2.tgz → tryghost-member-attribution-5.118.0.tgz} +0 -0
  16. package/components/tryghost-member-events-5.118.0.tgz +0 -0
  17. package/components/{tryghost-members-csv-5.116.2.tgz → tryghost-members-csv-5.118.0.tgz} +0 -0
  18. package/components/{tryghost-members-offers-5.116.2.tgz → tryghost-members-offers-5.118.0.tgz} +0 -0
  19. package/components/tryghost-mw-error-handler-5.118.0.tgz +0 -0
  20. package/components/tryghost-mw-vhost-5.118.0.tgz +0 -0
  21. package/components/{tryghost-post-events-5.116.2.tgz → tryghost-post-events-5.118.0.tgz} +0 -0
  22. package/components/tryghost-post-revisions-5.118.0.tgz +0 -0
  23. package/components/tryghost-posts-service-5.118.0.tgz +0 -0
  24. package/components/tryghost-prometheus-metrics-5.118.0.tgz +0 -0
  25. package/components/tryghost-security-5.118.0.tgz +0 -0
  26. package/components/tryghost-tiers-5.118.0.tgz +0 -0
  27. package/components/tryghost-webmentions-5.118.0.tgz +0 -0
  28. package/content/themes/casper/LICENSE +1 -1
  29. package/content/themes/casper/README.md +1 -1
  30. package/content/themes/casper/assets/built/screen.css +1 -1
  31. package/content/themes/casper/assets/built/screen.css.map +1 -1
  32. package/content/themes/casper/assets/css/screen.css +1 -1
  33. package/content/themes/casper/author.hbs +23 -2
  34. package/content/themes/casper/package.json +2 -2
  35. package/content/themes/casper/partials/icons/bluesky.hbs +3 -0
  36. package/content/themes/casper/partials/icons/instagram.hbs +5 -0
  37. package/content/themes/casper/partials/icons/linkedin.hbs +3 -0
  38. package/content/themes/casper/partials/icons/mastodon.hbs +3 -0
  39. package/content/themes/casper/partials/icons/threads.hbs +3 -0
  40. package/content/themes/casper/partials/icons/tiktok.hbs +3 -0
  41. package/content/themes/casper/partials/icons/twitter.hbs +3 -1
  42. package/content/themes/casper/partials/icons/youtube.hbs +3 -0
  43. package/content/themes/source/LICENSE +1 -1
  44. package/content/themes/source/README.md +1 -1
  45. package/content/themes/source/assets/built/screen.css +1 -1
  46. package/content/themes/source/assets/built/screen.css.map +1 -1
  47. package/content/themes/source/assets/css/screen.css +7 -12
  48. package/content/themes/source/author.hbs +24 -3
  49. package/content/themes/source/package.json +2 -2
  50. package/content/themes/source/partials/feature-image.hbs +2 -2
  51. package/content/themes/source/partials/icons/bluesky.hbs +3 -0
  52. package/content/themes/source/partials/icons/instagram.hbs +5 -0
  53. package/content/themes/source/partials/icons/linkedin.hbs +3 -0
  54. package/content/themes/source/partials/icons/mastodon.hbs +3 -0
  55. package/content/themes/source/partials/icons/threads.hbs +3 -0
  56. package/content/themes/source/partials/icons/tiktok.hbs +3 -0
  57. package/content/themes/source/partials/icons/youtube.hbs +3 -0
  58. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +31793 -26588
  59. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-550846e0.mjs → CodeEditorView-1143c509.mjs} +2 -2
  60. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  61. package/core/built/admin/assets/admin-x-settings/{index-f3cb3f4d.mjs → index-19ebc8ad.mjs} +2 -2
  62. package/core/built/admin/assets/admin-x-settings/{index-4ce2fcd1.mjs → index-ac104f42.mjs} +2635 -2607
  63. package/core/built/admin/assets/admin-x-settings/{modals-6bc20529.mjs → modals-994901ee.mjs} +6680 -6165
  64. package/core/built/admin/assets/{chunk.524.578de86e5014b911b05a.js → chunk.524.5710919eb507b9a81166.js} +8 -8
  65. package/core/built/admin/assets/{chunk.582.21bf3e37b5d84ac4b58a.js → chunk.582.c8cb99b85cfa13fc7df1.js} +10 -10
  66. package/core/built/admin/assets/{chunk.713.761d11035fe0bf3e557c.js → chunk.713.48f120c377bcaffdfddf.js} +6 -9
  67. package/core/built/admin/assets/{ghost-868c537d5c02ca65323d0122596a67ec.js → ghost-cd90a28b214ee800a007bb62cd45e6e6.js} +780 -775
  68. package/core/built/admin/assets/posts/posts.js +11561 -11302
  69. package/core/built/admin/assets/stats/stats.js +76076 -59355
  70. package/core/built/admin/index.html +4 -4
  71. package/core/frontend/helpers/social_url.js +31 -0
  72. package/core/server/api/endpoints/users.js +7 -0
  73. package/core/server/api/endpoints/utils/serializers/output/config.js +2 -1
  74. package/core/server/data/migrations/versions/5.117/2025-04-14-02-36-30-add-additional-social-accounts-columns-to-user-table.js +38 -0
  75. package/core/server/data/schema/schema.js +7 -0
  76. package/core/server/services/auth/session/index.js +5 -2
  77. package/core/server/services/auth/session/middleware.js +2 -1
  78. package/core/server/services/auth/session/session-service.js +7 -6
  79. package/core/server/services/members/api.js +2 -2
  80. package/core/server/services/members/members-api/controllers/MemberController.js +214 -0
  81. package/core/server/services/members/members-api/controllers/RouterController.js +667 -0
  82. package/core/server/services/members/members-api/controllers/WellKnownController.js +46 -0
  83. package/core/server/services/members/members-api/members-api.js +404 -0
  84. package/core/server/services/members/members-api/repositories/EventRepository.js +984 -0
  85. package/core/server/services/members/members-api/repositories/MemberRepository.js +1739 -0
  86. package/core/server/services/members/members-api/repositories/ProductRepository.js +662 -0
  87. package/core/server/services/members/members-api/services/GeolocationService.js +23 -0
  88. package/core/server/services/members/members-api/services/MemberBREADService.js +444 -0
  89. package/core/server/services/members/members-api/services/PaymentsService.js +522 -0
  90. package/core/server/services/members/members-api/services/TokenService.js +54 -0
  91. package/core/server/services/milestones/BookshelfMilestoneRepository.js +8 -9
  92. package/core/server/services/milestones/InMemoryMilestoneRepository.js +119 -0
  93. package/core/server/services/milestones/Milestone.js +231 -0
  94. package/core/server/services/milestones/MilestoneCreatedEvent.js +22 -0
  95. package/core/server/services/milestones/MilestonesService.js +327 -0
  96. package/core/server/services/milestones/service.js +2 -2
  97. package/core/server/services/newsletters/index.js +1 -1
  98. package/core/server/services/public-config/config.js +2 -1
  99. package/core/server/services/settings/settings-service.js +1 -1
  100. package/core/server/services/slack-notifications/SlackNotifications.js +1 -1
  101. package/core/server/services/slack-notifications/SlackNotificationsService.js +2 -2
  102. package/core/server/services/staff/StaffService.js +1 -1
  103. package/core/shared/config/defaults.json +3 -0
  104. package/core/shared/config/env/config.testing-mysql.json +3 -0
  105. package/core/shared/config/env/config.testing.json +3 -0
  106. package/core/shared/labs.js +2 -2
  107. package/package.json +63 -63
  108. package/tsconfig.tsbuildinfo +1 -1
  109. package/yarn.lock +306 -70
  110. package/components/tryghost-constants-5.116.2.tgz +0 -0
  111. package/components/tryghost-custom-fonts-5.116.2.tgz +0 -0
  112. package/components/tryghost-custom-theme-settings-service-5.116.2.tgz +0 -0
  113. package/components/tryghost-domain-events-5.116.2.tgz +0 -0
  114. package/components/tryghost-donations-5.116.2.tgz +0 -0
  115. package/components/tryghost-email-addresses-5.116.2.tgz +0 -0
  116. package/components/tryghost-email-suppression-list-5.116.2.tgz +0 -0
  117. package/components/tryghost-html-to-plaintext-5.116.2.tgz +0 -0
  118. package/components/tryghost-i18n-5.116.2.tgz +0 -0
  119. package/components/tryghost-link-replacer-5.116.2.tgz +0 -0
  120. package/components/tryghost-member-events-5.116.2.tgz +0 -0
  121. package/components/tryghost-members-api-5.116.2.tgz +0 -0
  122. package/components/tryghost-milestones-5.116.2.tgz +0 -0
  123. package/components/tryghost-mw-error-handler-5.116.2.tgz +0 -0
  124. package/components/tryghost-mw-vhost-5.116.2.tgz +0 -0
  125. package/components/tryghost-post-revisions-5.116.2.tgz +0 -0
  126. package/components/tryghost-posts-service-5.116.2.tgz +0 -0
  127. package/components/tryghost-prometheus-metrics-5.116.2.tgz +0 -0
  128. package/components/tryghost-security-5.116.2.tgz +0 -0
  129. package/components/tryghost-tiers-5.116.2.tgz +0 -0
  130. package/components/tryghost-webmentions-5.116.2.tgz +0 -0
  131. /package/core/built/admin/assets/{chunk.713.761d11035fe0bf3e557c.js.LICENSE.txt → chunk.713.48f120c377bcaffdfddf.js.LICENSE.txt} +0 -0
@@ -0,0 +1,984 @@
1
+ const errors = require('@tryghost/errors');
2
+ const nql = require('@tryghost/nql');
3
+ const mingo = require('mingo');
4
+ const {replaceFilters, expandFilters, splitFilter, getUsedKeys, chainTransformers, mapKeys, rejectStatements} = require('@tryghost/mongo-utils');
5
+
6
+ /**
7
+ * This mongo transformer ignores the provided filter option and replaces the filter with a custom filter that was provided to the transformer. Allowing us to set a mongo filter instead of a string based NQL filter.
8
+ */
9
+ function replaceCustomFilterTransformer(filter) {
10
+ // Instead of adding an existing filter, we replace a filter, because mongo transformers are only applied if there is any filter (so not executed for empty filters)
11
+ return function (existingFilter) {
12
+ return replaceFilters(existingFilter, {
13
+ custom: filter
14
+ });
15
+ };
16
+ }
17
+
18
+ module.exports = class EventRepository {
19
+ constructor({
20
+ DonationPaymentEvent,
21
+ EmailRecipient,
22
+ MemberSubscribeEvent,
23
+ MemberPaymentEvent,
24
+ MemberStatusEvent,
25
+ MemberLoginEvent,
26
+ MemberCreatedEvent,
27
+ SubscriptionCreatedEvent,
28
+ MemberPaidSubscriptionEvent,
29
+ MemberLinkClickEvent,
30
+ MemberFeedback,
31
+ EmailSpamComplaintEvent,
32
+ Comment,
33
+ labsService,
34
+ memberAttributionService,
35
+ MemberEmailChangeEvent
36
+ }) {
37
+ this._DonationPaymentEvent = DonationPaymentEvent;
38
+ this._MemberSubscribeEvent = MemberSubscribeEvent;
39
+ this._MemberPaidSubscriptionEvent = MemberPaidSubscriptionEvent;
40
+ this._MemberPaymentEvent = MemberPaymentEvent;
41
+ this._MemberStatusEvent = MemberStatusEvent;
42
+ this._MemberLoginEvent = MemberLoginEvent;
43
+ this._EmailRecipient = EmailRecipient;
44
+ this._Comment = Comment;
45
+ this._labsService = labsService;
46
+ this._MemberCreatedEvent = MemberCreatedEvent;
47
+ this._SubscriptionCreatedEvent = SubscriptionCreatedEvent;
48
+ this._MemberLinkClickEvent = MemberLinkClickEvent;
49
+ this._MemberFeedback = MemberFeedback;
50
+ this._EmailSpamComplaintEvent = EmailSpamComplaintEvent;
51
+ this._memberAttributionService = memberAttributionService;
52
+ this._MemberEmailChangeEvent = MemberEmailChangeEvent;
53
+ }
54
+
55
+ async getEventTimeline(options = {}) {
56
+ if (!options.limit) {
57
+ options.limit = 10;
58
+ }
59
+
60
+ const [typeFilter, otherFilter] = this.getNQLSubset(options.filter);
61
+
62
+ // Changing this order might need a change in the query functions
63
+ // because of the different underlying models.
64
+ options.order = 'created_at desc, id desc';
65
+
66
+ // Create a list of all events that can be queried
67
+ const pageActions = [
68
+ {type: 'comment_event', action: 'getCommentEvents'},
69
+ {type: 'click_event', action: 'getClickEvents'},
70
+ {type: 'aggregated_click_event', action: 'getAggregatedClickEvents'},
71
+ {type: 'signup_event', action: 'getSignupEvents'},
72
+ {type: 'subscription_event', action: 'getSubscriptionEvents'},
73
+ {type: 'donation_event', action: 'getDonationEvents'}
74
+ ];
75
+
76
+ // Some events are not filterable by post_id
77
+ if (!getUsedKeys(otherFilter).includes('data.post_id')) {
78
+ pageActions.push(
79
+ {type: 'newsletter_event', action: 'getNewsletterSubscriptionEvents'},
80
+ {type: 'login_event', action: 'getLoginEvents'},
81
+ {type: 'payment_event', action: 'getPaymentEvents'},
82
+ {type: 'email_change_event', action: 'getEmailChangeEvent'}
83
+ );
84
+ }
85
+
86
+ if (this._EmailRecipient) {
87
+ pageActions.push({type: 'email_sent_event', action: 'getEmailSentEvents'});
88
+ pageActions.push({type: 'email_delivered_event', action: 'getEmailDeliveredEvents'});
89
+ pageActions.push({type: 'email_opened_event', action: 'getEmailOpenedEvents'});
90
+ pageActions.push({type: 'email_failed_event', action: 'getEmailFailedEvents'});
91
+ }
92
+
93
+ pageActions.push({type: 'email_complained_event', action: 'getEmailSpamComplaintEvents'});
94
+
95
+ if (this._labsService.isSet('audienceFeedback')) {
96
+ pageActions.push({type: 'feedback_event', action: 'getFeedbackEvents'});
97
+ }
98
+
99
+ //Filter events to query
100
+ let filteredPages = pageActions;
101
+ if (typeFilter) {
102
+ // Ideally we should be able to create a NQL filter without having a string
103
+ const query = new mingo.Query(typeFilter);
104
+ filteredPages = filteredPages.filter(page => query.test(page));
105
+ }
106
+
107
+ //Start the promises
108
+ const pages = filteredPages.map((page) => {
109
+ return this[page.action](options, otherFilter);
110
+ });
111
+
112
+ const allEventPages = await Promise.all(pages);
113
+
114
+ const allEvents = allEventPages.flatMap(page => page.data);
115
+ const totalEvents = allEventPages.reduce((accumulator, page) => accumulator + page.meta.pagination.total, 0);
116
+
117
+ return {
118
+ events: allEvents.sort(
119
+ (a, b) => {
120
+ const diff = new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime();
121
+ if (diff !== 0) {
122
+ return diff;
123
+ }
124
+ return b.data.id.localeCompare(a.data.id);
125
+ }
126
+ ).slice(0, options.limit),
127
+ meta: {
128
+ pagination: {
129
+ limit: options.limit,
130
+ total: totalEvents,
131
+ pages: options.limit > 0 ? Math.ceil(totalEvents / options.limit) : null,
132
+
133
+ // Other values are unavailable (not possible to calculate easily)
134
+ page: null,
135
+ next: null,
136
+ prev: null
137
+ }
138
+ }
139
+ };
140
+ }
141
+
142
+ async registerPayment(data) {
143
+ await this._MemberPaymentEvent.add({
144
+ ...data,
145
+ source: 'stripe'
146
+ });
147
+ }
148
+
149
+ async getNewsletterSubscriptionEvents(options = {}, filter) {
150
+ options = {
151
+ ...options,
152
+ withRelated: ['member', 'newsletter'],
153
+ filter: 'custom:true',
154
+ useBasicCount: true,
155
+ mongoTransformer: chainTransformers(
156
+ // First set the filter manually
157
+ replaceCustomFilterTransformer(filter),
158
+
159
+ // Map the used keys in that filter
160
+ ...mapKeys({
161
+ 'data.created_at': 'created_at',
162
+ 'data.source': 'source',
163
+ 'data.member_id': 'member_id'
164
+ })
165
+ )
166
+ };
167
+
168
+ const {data: models, meta} = await this._MemberSubscribeEvent.findPage(options);
169
+
170
+ const data = models.map((model) => {
171
+ return {
172
+ type: 'newsletter_event',
173
+ data: model.toJSON(options)
174
+ };
175
+ });
176
+
177
+ return {
178
+ data,
179
+ meta
180
+ };
181
+ }
182
+
183
+ async getSubscriptionEvents(options = {}, filter) {
184
+ options = {
185
+ ...options,
186
+ withRelated: [
187
+ 'member',
188
+ 'subscriptionCreatedEvent.postAttribution',
189
+ 'subscriptionCreatedEvent.userAttribution',
190
+ 'subscriptionCreatedEvent.tagAttribution',
191
+ 'subscriptionCreatedEvent.memberCreatedEvent',
192
+
193
+ // This is rediculous, but we need the tier name (we'll be able to shorten this later when we switch to the subscriptions table)
194
+ 'stripeSubscription.stripePrice.stripeProduct.product'
195
+ ],
196
+ filter: 'custom:true',
197
+ useBasicCount: true,
198
+ mongoTransformer: chainTransformers(
199
+ // First set the filter manually
200
+ replaceCustomFilterTransformer(filter),
201
+
202
+ // Map the used keys in that filter
203
+ ...mapKeys({
204
+ 'data.created_at': 'created_at',
205
+ 'data.member_id': 'member_id'
206
+ }),
207
+
208
+ (f) => {
209
+ // Special one: when data.post_id is used, replace it with two filters: subscriptionCreatedEvent.attribution_id:x+subscriptionCreatedEvent.attribution_type:post
210
+ return expandFilters(f, [{
211
+ key: 'data.post_id',
212
+ replacement: 'subscriptionCreatedEvent.attribution_id',
213
+ expansion: {'subscriptionCreatedEvent.attribution_type': 'post', type: 'created'}
214
+ }]);
215
+ }
216
+ )
217
+ };
218
+
219
+ const {data: models, meta} = await this._MemberPaidSubscriptionEvent.findPage(options);
220
+
221
+ const data = models.map((model) => {
222
+ const tierName = model.related('stripeSubscription') && model.related('stripeSubscription').related('stripePrice') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product') ? model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product').get('name') : null;
223
+
224
+ // Prevent toJSON on stripeSubscription (we don't have everything loaded)
225
+ delete model.relations.stripeSubscription;
226
+ const d = {
227
+ ...model.toJSON(options),
228
+ attribution: model.get('type') === 'created' && model.related('subscriptionCreatedEvent') && model.related('subscriptionCreatedEvent').id ? this._memberAttributionService.getEventAttribution(model.related('subscriptionCreatedEvent')) : null,
229
+ signup: model.get('type') === 'created' && model.related('subscriptionCreatedEvent') && model.related('subscriptionCreatedEvent').id && model.related('subscriptionCreatedEvent').related('memberCreatedEvent') && model.related('subscriptionCreatedEvent').related('memberCreatedEvent').id ? true : false,
230
+ tierName
231
+ };
232
+ delete d.stripeSubscription;
233
+ return {
234
+ type: 'subscription_event',
235
+ data: d
236
+ };
237
+ });
238
+
239
+ return {
240
+ data,
241
+ meta
242
+ };
243
+ }
244
+
245
+ async getPaymentEvents(options = {}, filter) {
246
+ options = {
247
+ ...options,
248
+ withRelated: ['member'],
249
+ filter: 'custom:true',
250
+ useBasicCount: true,
251
+ mongoTransformer: chainTransformers(
252
+ // First set the filter manually
253
+ replaceCustomFilterTransformer(filter),
254
+
255
+ // Map the used keys in that filter
256
+ ...mapKeys({
257
+ 'data.created_at': 'created_at',
258
+ 'data.member_id': 'member_id'
259
+ })
260
+ )
261
+ };
262
+
263
+ const {data: models, meta} = await this._MemberPaymentEvent.findPage(options);
264
+
265
+ const data = models.map((model) => {
266
+ return {
267
+ type: 'payment_event',
268
+ data: model.toJSON(options)
269
+ };
270
+ });
271
+
272
+ return {
273
+ data,
274
+ meta
275
+ };
276
+ }
277
+
278
+ async getLoginEvents(options = {}, filter) {
279
+ options = {
280
+ ...options,
281
+ withRelated: ['member'],
282
+ filter: 'custom:true',
283
+ useBasicCount: true,
284
+ mongoTransformer: chainTransformers(
285
+ // First set the filter manually
286
+ replaceCustomFilterTransformer(filter),
287
+
288
+ // Map the used keys in that filter
289
+ ...mapKeys({
290
+ 'data.created_at': 'created_at',
291
+ 'data.member_id': 'member_id'
292
+ })
293
+ )
294
+ };
295
+
296
+ const {data: models, meta} = await this._MemberLoginEvent.findPage(options);
297
+
298
+ const data = models.map((model) => {
299
+ return {
300
+ type: 'login_event',
301
+ data: model.toJSON(options)
302
+ };
303
+ });
304
+
305
+ return {
306
+ data,
307
+ meta
308
+ };
309
+ }
310
+
311
+ async getSignupEvents(options = {}, filter) {
312
+ options = {
313
+ ...options,
314
+ withRelated: [
315
+ 'member',
316
+ 'postAttribution',
317
+ 'userAttribution',
318
+ 'tagAttribution'
319
+ ],
320
+ filter: 'subscriptionCreatedEvent.id:null+custom:true',
321
+ useBasicCount: true,
322
+ mongoTransformer: chainTransformers(
323
+ // First set the filter manually
324
+ replaceCustomFilterTransformer(filter),
325
+
326
+ // Map the used keys in that filter
327
+ ...mapKeys({
328
+ 'data.created_at': 'created_at',
329
+ 'data.member_id': 'member_id',
330
+ 'data.source': 'source'
331
+ }),
332
+
333
+ (f) => {
334
+ // Special one: when data.post_id is used, replace it with two filters: attribution_id:x+attribution_type:post
335
+ return expandFilters(f, [{
336
+ key: 'data.post_id',
337
+ replacement: 'attribution_id',
338
+ expansion: {attribution_type: 'post'}
339
+ }]);
340
+ }
341
+ )
342
+ };
343
+
344
+ const {data: models, meta} = await this._MemberCreatedEvent.findPage(options);
345
+
346
+ const data = models.map((model) => {
347
+ const json = model.toJSON(options);
348
+ delete json.postAttribution?.mobiledoc;
349
+ delete json.postAttribution?.lexical;
350
+ delete json.postAttribution?.plaintext;
351
+ return {
352
+ type: 'signup_event',
353
+ data: {
354
+ ...json,
355
+ attribution: this._memberAttributionService.getEventAttribution(model)
356
+ }
357
+ };
358
+ });
359
+
360
+ return {
361
+ data,
362
+ meta
363
+ };
364
+ }
365
+
366
+ async getDonationEvents(options = {}, filter) {
367
+ options = {
368
+ ...options,
369
+ withRelated: [
370
+ 'member',
371
+ 'postAttribution',
372
+ 'userAttribution',
373
+ 'tagAttribution'
374
+ ],
375
+ filter: 'member_id:-null+custom:true',
376
+ useBasicCount: true,
377
+ mongoTransformer: chainTransformers(
378
+ // First set the filter manually
379
+ replaceCustomFilterTransformer(filter),
380
+
381
+ // Map the used keys in that filter
382
+ ...mapKeys({
383
+ 'data.created_at': 'created_at',
384
+ 'data.member_id': 'member_id'
385
+ }),
386
+
387
+ (f) => {
388
+ // Special one: when data.post_id is used, replace it with two filters: attribution_id:x+attribution_type:post
389
+ return expandFilters(f, [{
390
+ key: 'data.post_id',
391
+ replacement: 'attribution_id',
392
+ expansion: {attribution_type: 'post'}
393
+ }]);
394
+ }
395
+ )
396
+ };
397
+
398
+ const {data: models, meta} = await this._DonationPaymentEvent.findPage(options);
399
+
400
+ const data = models.map((model) => {
401
+ const json = model.toJSON(options);
402
+ delete json.postAttribution?.mobiledoc;
403
+ delete json.postAttribution?.lexical;
404
+ delete json.postAttribution?.plaintext;
405
+ return {
406
+ type: 'donation_event',
407
+ data: {
408
+ ...json,
409
+ attribution: this._memberAttributionService.getEventAttribution(model)
410
+ }
411
+ };
412
+ });
413
+
414
+ return {
415
+ data,
416
+ meta
417
+ };
418
+ }
419
+
420
+ async getCommentEvents(options = {}, filter) {
421
+ options = {
422
+ ...options,
423
+ withRelated: ['member', 'post', 'parent'],
424
+ filter: 'member_id:-null+custom:true',
425
+ useBasicCount: true,
426
+ mongoTransformer: chainTransformers(
427
+ // First set the filter manually
428
+ replaceCustomFilterTransformer(filter),
429
+
430
+ // Map the used keys in that filter
431
+ ...mapKeys({
432
+ 'data.created_at': 'created_at',
433
+ 'data.member_id': 'member_id',
434
+ 'data.post_id': 'post_id'
435
+ })
436
+ )
437
+ };
438
+
439
+ const {data: models, meta} = await this._Comment.findPage(options);
440
+
441
+ const data = models.map((model) => {
442
+ return {
443
+ type: 'comment_event',
444
+ data: model.toJSON(options)
445
+ };
446
+ });
447
+
448
+ return {
449
+ data,
450
+ meta
451
+ };
452
+ }
453
+
454
+ async getClickEvents(options = {}, filter) {
455
+ options = {
456
+ ...options,
457
+ withRelated: ['member', 'link', 'link.post'],
458
+ filter: 'custom:true',
459
+ useBasicCount: true,
460
+ mongoTransformer: chainTransformers(
461
+ // First set the filter manually
462
+ replaceCustomFilterTransformer(filter),
463
+
464
+ // Map the used keys in that filter
465
+ ...mapKeys({
466
+ 'data.created_at': 'created_at',
467
+ 'data.member_id': 'member_id',
468
+ 'data.post_id': 'post_id'
469
+ })
470
+ )
471
+ };
472
+
473
+ const {data: models, meta} = await this._MemberLinkClickEvent.findPage(options);
474
+
475
+ const data = models.map((model) => {
476
+ return {
477
+ type: 'click_event',
478
+ data: model.toJSON(options)
479
+ };
480
+ });
481
+
482
+ return {
483
+ data,
484
+ meta
485
+ };
486
+ }
487
+
488
+ /**
489
+ * This groups click events per member for the same post, and only returns the first actual event, and includes the total clicks per event (for the same member and post)
490
+ */
491
+ async getAggregatedClickEvents(options = {}, filter) {
492
+ let postId = '';
493
+
494
+ if (filter && filter.$and) {
495
+ // Case when there is an $and condition
496
+ postId = filter.$and.find(condition => condition['data.post_id'])?.['data.post_id'];
497
+ } else {
498
+ // Case when there's no $and condition, directly look for data.post_id
499
+ postId = filter ? filter['data.post_id'] : '';
500
+ }
501
+
502
+ //Remove type filter as we don't need it in the query
503
+ const [typeFilter, otherFilter] = this.getNQLSubset(options.filter); // eslint-disable-line
504
+
505
+ filter = this.removePostIdFilter(otherFilter); //Remove post_id filter as we don't need it in the query
506
+
507
+ let postClicksQuery = postId && postId !== '' ? `SELECT
508
+ mce.id,
509
+ mce.member_id,
510
+ mce.redirect_id,
511
+ mce.created_at
512
+ FROM
513
+ members_click_events mce
514
+ INNER JOIN
515
+ redirects r ON mce.redirect_id = r.id
516
+ WHERE
517
+ r.post_id = '${postId}'
518
+ `
519
+ : `SELECT
520
+ mce.id,
521
+ mce.member_id,
522
+ mce.redirect_id,
523
+ mce.created_at
524
+ FROM
525
+ members_click_events mce
526
+ INNER JOIN
527
+ redirects r ON mce.redirect_id = r.id
528
+ `;
529
+
530
+ const firstClicksQuery = `
531
+ SELECT
532
+ id,
533
+ member_id,
534
+ redirect_id,
535
+ created_at,
536
+ ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY created_at, id) AS rn
537
+ FROM
538
+ PostClicks
539
+ `;
540
+
541
+ const mainQuery = `SELECT COUNT(DISTINCT redirect_id)
542
+ FROM PostClicks AS inner_mce
543
+ WHERE inner_mce.member_id = FirstClicks.member_id
544
+ AND inner_mce.redirect_id IN (
545
+ SELECT redirect_id
546
+ FROM PostClicks
547
+ )`;
548
+ options = {
549
+ ...options,
550
+ withRelated: ['member'],
551
+ filterRelations: false,
552
+ filter: 'custom:true',
553
+ useBasicCount: true,
554
+ mongoTransformer: chainTransformers(
555
+ // First set the filter manually
556
+ replaceCustomFilterTransformer(filter),
557
+
558
+ // Map the used keys in that filter
559
+ ...mapKeys({
560
+ 'data.created_at': 'created_at',
561
+ 'data.member_id': 'member_id',
562
+ 'data.post_id': 'post_id'
563
+ })
564
+ ),
565
+ useCTE: true,
566
+ // We need to use MIN to make pagination work correctly
567
+ // Note: we cannot do `count(distinct redirect_id) as count__clicks`, because we don't want the created_at filter to affect that count
568
+ // For pagination to work correctly, we also need to return the id of the first event (or the minimum id if multiple events happend at the same time, but should be the first). Just MIN(id) won't work because that value changes if filter created_at < x is applied.
569
+ selectRaw: `id, member_id, created_at, (${mainQuery}) as count__clicks`,
570
+ whereRaw: `rn = 1 ORDER BY created_at DESC, id DESC`,
571
+ cte: [{
572
+ name: `PostClicks`,
573
+ query: postClicksQuery
574
+ },
575
+ {
576
+ name: `FirstClicks`,
577
+ query: firstClicksQuery
578
+ }],
579
+ from: 'FirstClicks',
580
+ order: ''
581
+ };
582
+
583
+ const {data: models, meta} = await this._MemberLinkClickEvent.findPage(options);
584
+
585
+ const data = models.map((model) => {
586
+ return {
587
+ type: 'aggregated_click_event',
588
+ data: model.toJSON(options)
589
+ };
590
+ });
591
+
592
+ return {
593
+ data,
594
+ meta
595
+ };
596
+ }
597
+
598
+ async getFeedbackEvents(options = {}, filter) {
599
+ options = {
600
+ ...options,
601
+ withRelated: ['member', 'post'],
602
+ filter: 'custom:true',
603
+ useBasicCount: true,
604
+ mongoTransformer: chainTransformers(
605
+ // First set the filter manually
606
+ replaceCustomFilterTransformer(filter),
607
+
608
+ // Map the used keys in that filter
609
+ ...mapKeys({
610
+ 'data.created_at': 'created_at',
611
+ 'data.member_id': 'member_id',
612
+ 'data.post_id': 'post_id'
613
+ })
614
+ )
615
+ };
616
+
617
+ const {data: models, meta} = await this._MemberFeedback.findPage(options);
618
+
619
+ const data = models.map((model) => {
620
+ return {
621
+ type: 'feedback_event',
622
+ data: model.toJSON(options)
623
+ };
624
+ });
625
+
626
+ return {
627
+ data,
628
+ meta
629
+ };
630
+ }
631
+
632
+ async getEmailSentEvents(options = {}, filter) {
633
+ const filterStr = 'failed_at:null+processed_at:-null+delivered_at:null+custom:true';
634
+ options = {
635
+ ...options,
636
+ withRelated: ['member', 'email'],
637
+ filter: filterStr,
638
+ useBasicCount: true,
639
+ mongoTransformer: chainTransformers(
640
+ // First set the filter manually
641
+ replaceCustomFilterTransformer(filter),
642
+
643
+ // Map the used keys in that filter
644
+ ...mapKeys({
645
+ 'data.created_at': 'processed_at',
646
+ 'data.member_id': 'member_id',
647
+ 'data.post_id': 'email.post_id'
648
+ })
649
+ )
650
+ };
651
+ options.order = options.order.replace(/created_at/g, 'processed_at');
652
+
653
+ const {data: models, meta} = await this._EmailRecipient.findPage(
654
+ options
655
+ );
656
+
657
+ const data = models.map((model) => {
658
+ return {
659
+ type: 'email_sent_event',
660
+ data: {
661
+ id: model.id,
662
+ member_id: model.get('member_id'),
663
+ created_at: model.get('processed_at'),
664
+ member: model.related('member').toJSON(),
665
+ email: model.related('email').toJSON()
666
+ }
667
+ };
668
+ });
669
+
670
+ return {
671
+ data,
672
+ meta
673
+ };
674
+ }
675
+
676
+ async getEmailDeliveredEvents(options = {}, filter) {
677
+ options = {
678
+ ...options,
679
+ withRelated: ['member', 'email'],
680
+ filter: 'delivered_at:-null+custom:true',
681
+ useBasicCount: true,
682
+ mongoTransformer: chainTransformers(
683
+ // First set the filter manually
684
+ replaceCustomFilterTransformer(filter),
685
+
686
+ // Map the used keys in that filter
687
+ ...mapKeys({
688
+ 'data.created_at': 'delivered_at',
689
+ 'data.member_id': 'member_id',
690
+ 'data.post_id': 'email.post_id'
691
+ })
692
+ )
693
+ };
694
+ options.order = options.order.replace(/created_at/g, 'delivered_at');
695
+
696
+ const {data: models, meta} = await this._EmailRecipient.findPage(
697
+ options
698
+ );
699
+
700
+ const data = models.map((model) => {
701
+ return {
702
+ type: 'email_delivered_event',
703
+ data: {
704
+ id: model.id,
705
+ member_id: model.get('member_id'),
706
+ created_at: model.get('delivered_at'),
707
+ member: model.related('member').toJSON(),
708
+ email: model.related('email').toJSON()
709
+ }
710
+ };
711
+ });
712
+
713
+ return {
714
+ data,
715
+ meta
716
+ };
717
+ }
718
+
719
+ async getEmailOpenedEvents(options = {}, filter) {
720
+ options = {
721
+ ...options,
722
+ withRelated: ['member', 'email'],
723
+ filter: 'opened_at:-null+custom:true',
724
+ useBasicCount: true,
725
+ mongoTransformer: chainTransformers(
726
+ // First set the filter manually
727
+ replaceCustomFilterTransformer(filter),
728
+
729
+ // Map the used keys in that filter
730
+ ...mapKeys({
731
+ 'data.created_at': 'opened_at',
732
+ 'data.member_id': 'member_id',
733
+ 'data.post_id': 'email.post_id'
734
+ })
735
+ )
736
+ };
737
+ options.order = options.order.replace(/created_at/g, 'opened_at');
738
+
739
+ const {data: models, meta} = await this._EmailRecipient.findPage(
740
+ options
741
+ );
742
+
743
+ const data = models.map((model) => {
744
+ return {
745
+ type: 'email_opened_event',
746
+ data: {
747
+ id: model.id,
748
+ member_id: model.get('member_id'),
749
+ created_at: model.get('opened_at'),
750
+ member: model.related('member').toJSON(),
751
+ email: model.related('email').toJSON()
752
+ }
753
+ };
754
+ });
755
+
756
+ return {
757
+ data,
758
+ meta
759
+ };
760
+ }
761
+
762
+ async getEmailSpamComplaintEvents(options = {}, filter) {
763
+ options = {
764
+ ...options,
765
+ withRelated: ['member', 'email'],
766
+ filter: 'custom:true',
767
+ useBasicCount: true,
768
+ mongoTransformer: chainTransformers(
769
+ // First set the filter manually
770
+ replaceCustomFilterTransformer(filter),
771
+
772
+ // Map the used keys in that filter
773
+ ...mapKeys({
774
+ 'data.created_at': 'created_at',
775
+ 'data.member_id': 'member_id',
776
+ 'data.post_id': 'email.post_id'
777
+ })
778
+ )
779
+ };
780
+
781
+ const {data: models, meta} = await this._EmailSpamComplaintEvent.findPage(options);
782
+
783
+ const data = models.map((model) => {
784
+ return {
785
+ type: 'email_complaint_event',
786
+ data: model.toJSON(options)
787
+ };
788
+ });
789
+
790
+ return {
791
+ data,
792
+ meta
793
+ };
794
+ }
795
+
796
+ async getEmailFailedEvents(options = {}, filter) {
797
+ options = {
798
+ ...options,
799
+ withRelated: ['member', 'email'],
800
+ filter: 'failed_at:-null+custom:true',
801
+ useBasicCount: true,
802
+ mongoTransformer: chainTransformers(
803
+ // First set the filter manually
804
+ replaceCustomFilterTransformer(filter),
805
+
806
+ // Map the used keys in that filter
807
+ ...mapKeys({
808
+ 'data.created_at': 'failed_at',
809
+ 'data.member_id': 'member_id',
810
+ 'data.post_id': 'email.post_id'
811
+ })
812
+ )
813
+ };
814
+ options.order = options.order.replace(/created_at/g, 'failed_at');
815
+
816
+ const {data: models, meta} = await this._EmailRecipient.findPage(
817
+ options
818
+ );
819
+
820
+ const data = models.map((model) => {
821
+ return {
822
+ type: 'email_failed_event',
823
+ data: {
824
+ id: model.id,
825
+ member_id: model.get('member_id'),
826
+ created_at: model.get('failed_at'),
827
+ member: model.related('member').toJSON(),
828
+ email: model.related('email').toJSON()
829
+ }
830
+ };
831
+ });
832
+
833
+ return {
834
+ data,
835
+ meta
836
+ };
837
+ }
838
+
839
+ async getEmailChangeEvent(options = {}, filter) {
840
+ options = {
841
+ ...options,
842
+ withRelated: ['member'],
843
+ filter: 'custom:true',
844
+ useBasicCount: true,
845
+ mongoTransformer: chainTransformers(
846
+ // First set the filter manually
847
+ replaceCustomFilterTransformer(filter),
848
+
849
+ // Map the used keys in that filter
850
+ ...mapKeys({
851
+ 'data.created_at': 'created_at',
852
+ 'data.member_id': 'member_id'
853
+ })
854
+ )
855
+ };
856
+
857
+ const {data: models, meta} = await this._MemberEmailChangeEvent.findPage(options);
858
+
859
+ const data = models.map((model) => {
860
+ return {
861
+ type: 'email_change_event',
862
+ data: model.toJSON(options)
863
+ };
864
+ });
865
+
866
+ return {
867
+ data,
868
+ meta
869
+ };
870
+ }
871
+
872
+ /**
873
+ * Split the filter in two parts:
874
+ * - One with 'type' that will be applied to all the pages
875
+ * - Other filter that will be applied to each individual page
876
+ *
877
+ * Throws if splitting is not possible (e.g. OR'ing type with other filters)
878
+ */
879
+ getNQLSubset(filter) {
880
+ if (!filter) {
881
+ return [undefined, undefined];
882
+ }
883
+
884
+ const allowList = ['data.created_at', 'data.member_id', 'data.post_id', 'type', 'id'];
885
+ let parsed;
886
+ try {
887
+ parsed = nql(filter).parse();
888
+ } catch (e) {
889
+ throw new errors.BadRequestError({
890
+ message: e.message
891
+ });
892
+ }
893
+
894
+ const keys = getUsedKeys(parsed);
895
+
896
+ for (const key of keys) {
897
+ if (!allowList.includes(key)) {
898
+ throw new errors.IncorrectUsageError({
899
+ message: 'Cannot filter by ' + key
900
+ });
901
+ }
902
+ }
903
+
904
+ try {
905
+ return splitFilter(parsed, ['type']);
906
+ } catch (e) {
907
+ throw new errors.IncorrectUsageError({
908
+ message: e.message
909
+ });
910
+ }
911
+ }
912
+
913
+ removePostIdFilter(filter) {
914
+ if (!filter) {
915
+ return filter;
916
+ }
917
+
918
+ try {
919
+ return rejectStatements(filter, key => key === 'data.post_id');
920
+ } catch (e) {
921
+ throw new errors.IncorrectUsageError({
922
+ message: e.message
923
+ });
924
+ }
925
+ }
926
+
927
+ async getMRR() {
928
+ const results = await this._MemberPaidSubscriptionEvent.findAll({
929
+ aggregateMRRDeltas: true
930
+ });
931
+
932
+ const resultsJSON = results.toJSON();
933
+
934
+ const cumulativeResults = resultsJSON.reduce((accumulator, result) => {
935
+ if (!accumulator[result.currency]) {
936
+ return {
937
+ ...accumulator,
938
+ [result.currency]: [{
939
+ date: result.date,
940
+ mrr: result.mrr_delta,
941
+ currency: result.currency
942
+ }]
943
+ };
944
+ }
945
+ return {
946
+ ...accumulator,
947
+ [result.currency]: accumulator[result.currency].concat([{
948
+ date: result.date,
949
+ mrr: result.mrr_delta + accumulator[result.currency].slice(-1)[0].mrr,
950
+ currency: result.currency
951
+ }])
952
+ };
953
+ }, {});
954
+
955
+ return cumulativeResults;
956
+ }
957
+
958
+ async getStatuses() {
959
+ const results = await this._MemberStatusEvent.findAll({
960
+ aggregateStatusCounts: true
961
+ });
962
+
963
+ const resultsJSON = results.toJSON();
964
+
965
+ const cumulativeResults = resultsJSON.reduce((accumulator, result, index) => {
966
+ if (index === 0) {
967
+ return [{
968
+ date: result.date,
969
+ paid: result.paid_delta,
970
+ comped: result.comped_delta,
971
+ free: result.free_delta
972
+ }];
973
+ }
974
+ return accumulator.concat([{
975
+ date: result.date,
976
+ paid: result.paid_delta + accumulator[index - 1].paid,
977
+ comped: result.comped_delta + accumulator[index - 1].comped,
978
+ free: result.free_delta + accumulator[index - 1].free
979
+ }]);
980
+ }, []);
981
+
982
+ return cumulativeResults;
983
+ }
984
+ };