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,119 @@
1
+ /**
2
+ * @typedef {import('./Milestone')} Milestone
3
+ * @typedef {import('./MilestonesService').IMilestoneRepository} IMilestoneRepository
4
+ */
5
+
6
+ /**
7
+ * @implements {IMilestoneRepository}
8
+ */
9
+ module.exports = class InMemoryMilestoneRepository {
10
+ /** @type {Milestone[]} */
11
+ #store = [];
12
+
13
+ /** @type {Object.<string, true>} */
14
+ #ids = {};
15
+
16
+ /** @type {import('@tryghost/domain-events')} */
17
+ #DomainEvents;
18
+
19
+ /**
20
+ * @param {object} deps
21
+ * @param {import('@tryghost/domain-events')} deps.DomainEvents
22
+ */
23
+ constructor(deps) {
24
+ this.#DomainEvents = deps.DomainEvents;
25
+ }
26
+
27
+ /**
28
+ * @param {Milestone} milestone
29
+ *
30
+ * @returns {Promise<void>}
31
+ */
32
+ async save(milestone) {
33
+ if (this.#ids[milestone.id.toHexString()]) {
34
+ const existingIndex = this.#store.findIndex((item) => {
35
+ return item.id.equals(milestone.id);
36
+ });
37
+ this.#store.splice(existingIndex, 1, milestone);
38
+ } else {
39
+ this.#store.push(milestone);
40
+ this.#ids[milestone.id.toHexString()] = true;
41
+ }
42
+
43
+ for (const event of milestone.events) {
44
+ this.#DomainEvents.dispatch(event);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @param {'arr'|'members'} type
50
+ * @param {string} [currency]
51
+ *
52
+ * @returns {Promise<Milestone>}
53
+ */
54
+ async getLatestByType(type, currency = 'usd') {
55
+ const allMilestonesForType = await this.getAllByType(type, currency);
56
+ return allMilestonesForType?.[0];
57
+ }
58
+
59
+ /**
60
+ * @returns {Promise<Milestone>}
61
+ */
62
+ async getLastEmailSent() {
63
+ return this.#store
64
+ .filter(item => item.emailSentAt)
65
+ // sort by emailSentAt desc
66
+ .sort((a, b) => (b.emailSentAt.valueOf() - a.emailSentAt.valueOf()))
67
+ // if we end up with more values with the same datetime, pick the highest value
68
+ .sort((a, b) => b.value - a.value)[0];
69
+ }
70
+
71
+ /**
72
+ * @param {number} value
73
+ * @param {string} [currency]
74
+ *
75
+ * @returns {Promise<Milestone>}
76
+ */
77
+ async getByARR(value, currency = 'usd') {
78
+ // find a milestone of the ARR type by a given value
79
+ return this.#store.find((item) => {
80
+ return item.value === value && item.type === 'arr' && item.currency === currency;
81
+ });
82
+ }
83
+
84
+ /**
85
+ * @param {number} value
86
+ *
87
+ * @returns {Promise<Milestone>}
88
+ */
89
+ async getByCount(value) {
90
+ // find a milestone of the members type by a given value
91
+ return this.#store.find((item) => {
92
+ return item.value === value && item.type === 'members';
93
+ });
94
+ }
95
+
96
+ /**
97
+ * @param {'arr'|'members'} type
98
+ * @param {string} [currency]
99
+ *
100
+ * @returns {Promise<Milestone[]>}
101
+ */
102
+ async getAllByType(type, currency = 'usd') {
103
+ if (type === 'arr') {
104
+ return this.#store
105
+ .filter(item => item.type === type && item.currency === currency)
106
+ // sort by created at desc
107
+ .sort((a, b) => (b.createdAt.valueOf() - a.createdAt.valueOf()))
108
+ // sort by highest value
109
+ .sort((a, b) => b.value - a.value);
110
+ } else {
111
+ return this.#store
112
+ .filter(item => item.type === type)
113
+ // sort by created at desc
114
+ .sort((a, b) => (b.createdAt.valueOf() - a.createdAt.valueOf()))
115
+ // sort by highest value
116
+ .sort((a, b) => b.value - a.value);
117
+ }
118
+ }
119
+ };
@@ -0,0 +1,231 @@
1
+ const ObjectID = require('bson-objectid').default;
2
+ const {ValidationError} = require('@tryghost/errors');
3
+ const MilestoneCreatedEvent = require('./MilestoneCreatedEvent');
4
+
5
+ module.exports = class Milestone {
6
+ /** @type {Array} */
7
+ events = [];
8
+
9
+ /**
10
+ * @type {ObjectID}
11
+ */
12
+ #id;
13
+ get id() {
14
+ return this.#id;
15
+ }
16
+
17
+ /**
18
+ * @type {'arr'|'members'}
19
+ */
20
+ #type;
21
+ get type() {
22
+ return this.#type;
23
+ }
24
+
25
+ /** @type {number} */
26
+ #value;
27
+ get value() {
28
+ return this.#value;
29
+ }
30
+
31
+ /** @type {string} */
32
+ #currency;
33
+ get currency() {
34
+ return this.#currency;
35
+ }
36
+
37
+ /** @type {Date} */
38
+ #createdAt;
39
+ get createdAt() {
40
+ return this.#createdAt;
41
+ }
42
+
43
+ /** @type {Date|null} */
44
+ #emailSentAt;
45
+ get emailSentAt() {
46
+ return this.#emailSentAt;
47
+ }
48
+
49
+ toJSON() {
50
+ return {
51
+ id: this.id,
52
+ name: this.name,
53
+ type: this.type,
54
+ value: this.value,
55
+ currency: this.currency,
56
+ createdAt: this.createdAt,
57
+ emailSentAt: this.emailSentAt
58
+ };
59
+ }
60
+
61
+ /** @private */
62
+ constructor(data) {
63
+ this.#id = data.id;
64
+ this.#type = data.type;
65
+ this.#value = data.value;
66
+ this.#currency = data.currency;
67
+ this.#createdAt = data.createdAt;
68
+ this.#emailSentAt = data.emailSentAt;
69
+ }
70
+
71
+ /**
72
+ * @returns {string}
73
+ */
74
+ get name() {
75
+ if (this.type === 'arr') {
76
+ return `arr-${this.value}-${this.currency}`;
77
+ }
78
+ return `members-${this.value}`;
79
+ }
80
+
81
+ /**
82
+ * @param {any} data
83
+ * @returns {Promise<Milestone>}
84
+ */
85
+ static async create(data) {
86
+ /** @type ObjectID */
87
+ let id;
88
+ let isNew = false;
89
+ if (!data.id) {
90
+ isNew = true;
91
+ id = new ObjectID();
92
+ } else if (typeof data.id === 'string') {
93
+ id = ObjectID.createFromHexString(data.id);
94
+ } else if (data.id instanceof ObjectID) {
95
+ id = data.id;
96
+ } else {
97
+ throw new ValidationError({
98
+ message: 'Invalid ID provided for Milestone'
99
+ });
100
+ }
101
+
102
+ const type = validateType(data.type);
103
+ const currency = validateCurrency(type, data?.currency);
104
+ const value = validateValue(data.value);
105
+ const name = validateName(data.name, value, type, currency);
106
+ const emailSentAt = validateEmailSentAt(data);
107
+
108
+ /** @type Date */
109
+ let createdAt;
110
+ if (data.createdAt instanceof Date) {
111
+ createdAt = data.createdAt;
112
+ } else if (data.createdAt) {
113
+ createdAt = new Date(data.createdAt);
114
+ if (isNaN(createdAt.valueOf())) {
115
+ throw new ValidationError({
116
+ message: 'Invalid Date'
117
+ });
118
+ }
119
+ } else {
120
+ createdAt = new Date();
121
+ }
122
+
123
+ const milestone = new Milestone({
124
+ id,
125
+ name,
126
+ type,
127
+ value,
128
+ currency,
129
+ createdAt,
130
+ emailSentAt
131
+ });
132
+
133
+ if (isNew) {
134
+ milestone.events.push(MilestoneCreatedEvent.create({milestone, meta: data?.meta}));
135
+ }
136
+
137
+ return milestone;
138
+ }
139
+ };
140
+
141
+ /**
142
+ *
143
+ * @param {number|null} value
144
+ *
145
+ * @returns {number}
146
+ */
147
+ function validateValue(value) {
148
+ if (value === undefined || typeof value !== 'number') {
149
+ throw new ValidationError({
150
+ message: 'Invalid value'
151
+ });
152
+ }
153
+
154
+ return value;
155
+ }
156
+
157
+ /**
158
+ *
159
+ * @param {unknown} type
160
+ *
161
+ * @returns {'arr'|'members'}
162
+ */
163
+ function validateType(type) {
164
+ if (type === 'arr') {
165
+ return 'arr';
166
+ }
167
+
168
+ return 'members';
169
+ }
170
+
171
+ /**
172
+ *
173
+ * @param {'arr'|'members'} type
174
+ * @param {string|null} currency
175
+ *
176
+ * @returns {string}
177
+ */
178
+ function validateCurrency(type, currency) {
179
+ if (type === 'members') {
180
+ return null;
181
+ }
182
+
183
+ if (!currency || (currency && typeof currency !== 'string' || currency.length > 3)) {
184
+ return 'usd';
185
+ }
186
+
187
+ return currency;
188
+ }
189
+
190
+ /**
191
+ *
192
+ * @param {string} name
193
+ * @param {number} value
194
+ * @param {'arr'|'members'} type
195
+ * @param {string|null} currency
196
+ *
197
+ * @returns {string}
198
+ */
199
+ function validateName(name, value, type, currency) {
200
+ if (!name || !name.match(/(arr|members)-\d*/i)) {
201
+ return type === 'arr' ? `${type}-${value}-${currency}` : `${type}-${value}`;
202
+ }
203
+
204
+ return name;
205
+ }
206
+
207
+ /**
208
+ *
209
+ * @param {object} data
210
+ * @param {Date|null} data.emailSentAt
211
+ *
212
+ * @returns {Date|null}
213
+ */
214
+ function validateEmailSentAt(data) {
215
+ let emailSentAt;
216
+ if (data.emailSentAt instanceof Date) {
217
+ emailSentAt = data.emailSentAt;
218
+ } else if (data.emailSentAt) {
219
+ emailSentAt = new Date(data.emailSentAt);
220
+ if (isNaN(emailSentAt.valueOf())) {
221
+ throw new ValidationError({
222
+ message: 'Invalid Date'
223
+ });
224
+ }
225
+ } else {
226
+ emailSentAt = null;
227
+ }
228
+
229
+ return emailSentAt;
230
+ }
231
+
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @typedef {object} MilestoneCreatedEventData
3
+ */
4
+
5
+ module.exports = class MilestoneCreatedEvent {
6
+ /**
7
+ * @param {MilestoneCreatedEventData} data
8
+ * @param {Date} timestamp
9
+ */
10
+ constructor(data, timestamp) {
11
+ this.data = data;
12
+ this.timestamp = timestamp;
13
+ }
14
+
15
+ /**
16
+ * @param {MilestoneCreatedEventData} data
17
+ * @param {Date} [timestamp]
18
+ */
19
+ static create(data, timestamp) {
20
+ return new MilestoneCreatedEvent(data, timestamp ?? new Date);
21
+ }
22
+ };
@@ -0,0 +1,327 @@
1
+ const Milestone = require('./Milestone');
2
+
3
+ /**
4
+ * @typedef {object} IMilestoneRepository
5
+ * @prop {(milestone: Milestone) => Promise<void>} save
6
+ * @prop {(arr: number, [currency]: string|null) => Promise<Milestone>} getByARR
7
+ * @prop {(count: number) => Promise<Milestone>} getByCount
8
+ * @prop {(type: 'arr'|'members', [currency]: string|null) => Promise<Milestone>} getLatestByType
9
+ * @prop {() => Promise<Milestone>} getLastEmailSent
10
+ * @prop {(type: 'arr'|'members', [currency]: string|null) => Promise<Milestone[]>} getAllByType
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} IQueries
15
+ * @prop {() => Promise<number>} getMembersCount
16
+ * @prop {() => Promise<object>} getARR
17
+ * @prop {() => Promise<boolean>} hasImportedMembersInPeriod
18
+ * @prop {() => Promise<string>} getDefaultCurrency
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} milestonesConfig
23
+ * @prop {Array<object>} milestonesConfig.arr
24
+ * @prop {string} milestonesConfig.arr.currency
25
+ * @prop {number[]} milestonesConfig.arr.values
26
+ * @prop {number[]} milestonesConfig.members
27
+ * @prop {number} milestonesConfig.minDaysSinceLastEmail
28
+ */
29
+
30
+ module.exports = class MilestonesService {
31
+ /** @type {IMilestoneRepository} */
32
+ #repository;
33
+
34
+ /**
35
+ * @type {milestonesConfig} */
36
+ #milestonesConfig;
37
+
38
+ /** @type {IQueries} */
39
+ #queries;
40
+
41
+ /**
42
+ * @param {object} deps
43
+ * @param {IMilestoneRepository} deps.repository
44
+ * @param {milestonesConfig} deps.milestonesConfig
45
+ * @param {IQueries} deps.queries
46
+ */
47
+ constructor(deps) {
48
+ this.#milestonesConfig = deps.milestonesConfig;
49
+ this.#queries = deps.queries;
50
+ this.#repository = deps.repository;
51
+ }
52
+
53
+ /**
54
+ * @param {string} [currency]
55
+ *
56
+ * @returns {Promise<Milestone>}
57
+ */
58
+ async #getLatestArrMilestone(currency = 'usd') {
59
+ return this.#repository.getLatestByType('arr', currency);
60
+ }
61
+
62
+ /**
63
+ * @returns {Promise<Milestone>}
64
+ */
65
+ async #getLatestMembersCountMilestone() {
66
+ return this.#repository.getLatestByType('members', null);
67
+ }
68
+
69
+ /**
70
+ * @returns {Promise<string>}
71
+ */
72
+ async #getDefaultCurrency() {
73
+ return await this.#queries.getDefaultCurrency();
74
+ }
75
+
76
+ /**
77
+ * @param {object} milestone
78
+ * @param {'arr'|'members'} milestone.type
79
+ * @param {number} milestone.value
80
+ * @param {string} milestone.currency
81
+ *
82
+ * @returns {Promise<boolean>}
83
+ */
84
+ async #checkMilestoneExists(milestone) {
85
+ let foundExistingMilestone = false;
86
+ let existingMilestone = null;
87
+
88
+ if (milestone.type === 'arr') {
89
+ existingMilestone = await this.#repository.getByARR(milestone.value, milestone.currency) || false;
90
+ } else if (milestone.type === 'members') {
91
+ existingMilestone = await this.#repository.getByCount(milestone.value) || false;
92
+ }
93
+
94
+ foundExistingMilestone = existingMilestone ? true : false;
95
+
96
+ return foundExistingMilestone;
97
+ }
98
+
99
+ /**
100
+ * @param {object} milestone
101
+ * @param {'arr'|'members'} milestone.type
102
+ * @param {number} milestone.value
103
+ *
104
+ * @returns {Promise<Milestone>}
105
+ */
106
+ async #createMilestone(milestone) {
107
+ const newMilestone = await Milestone.create(milestone);
108
+
109
+ await this.#repository.save(newMilestone);
110
+ return newMilestone;
111
+ }
112
+
113
+ /**
114
+ *
115
+ * @param {number[]} goalValues
116
+ * @param {number} current
117
+ *
118
+ * @returns {number[]}
119
+ */
120
+ #getMatchedMilestones(goalValues, current) {
121
+ // return all achieved milestones and sort by value ascending
122
+ return goalValues.filter(value => current >= value)
123
+ .sort((a, b) => a - b);
124
+ }
125
+
126
+ /**
127
+ *
128
+ * @param {object} milestone
129
+ * @param {number} milestone.value
130
+ * @param {'arr'|'members'} milestone.type
131
+ * @param {object} milestone.meta
132
+ * @param {string|null} [milestone.currency]
133
+ * @param {Date|null} [milestone.emailSentAt]
134
+ *
135
+ * @returns {Promise<Milestone>}
136
+ */
137
+ async #saveMileStoneAndSendEmail(milestone) {
138
+ const {shouldSendEmail, reason} = await this.#shouldSendEmail();
139
+
140
+ if (shouldSendEmail) {
141
+ milestone.emailSentAt = new Date();
142
+ }
143
+
144
+ if (reason) {
145
+ milestone.meta.reason = reason;
146
+ }
147
+
148
+ return await this.#createMilestone(milestone);
149
+ }
150
+
151
+ /**
152
+ * @param {object} milestone
153
+ * @param {number} milestone.value
154
+ * @param {'arr'|'members'} milestone.type
155
+ * @param {object} milestone.meta
156
+ * @param {string|null} [milestone.currency]
157
+ * @param {Date|null} [milestone.emailSentAt]
158
+ *
159
+ * @returns {Promise<Milestone>}
160
+ */
161
+ async #saveMileStoneWithoutEmail(milestone) {
162
+ return await this.#createMilestone(milestone);
163
+ }
164
+
165
+ /**
166
+ * @returns {Promise<{shouldSendEmail: boolean, reason: string}>}
167
+ */
168
+ async #shouldSendEmail() {
169
+ let emailTooSoon = false;
170
+ let reason = null;
171
+ // Two cases in which we don't want to send an email
172
+ // 1. There has been an import of members within the last week
173
+ // 2. The last email has been sent less than two weeks ago
174
+ const lastMilestoneSent = await this.#repository.getLastEmailSent();
175
+
176
+ if (lastMilestoneSent) {
177
+ const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime();
178
+ const differenceInDays = differenceInTime / (1000 * 3600 * 24);
179
+
180
+ emailTooSoon = differenceInDays <= this.#milestonesConfig.minDaysSinceLastEmail;
181
+ }
182
+
183
+ const hasMembersImported = await this.#queries.hasImportedMembersInPeriod();
184
+ const shouldSendEmail = !emailTooSoon && !hasMembersImported;
185
+
186
+ if (!shouldSendEmail) {
187
+ reason = hasMembersImported ? 'import' : 'email';
188
+ }
189
+
190
+ return {shouldSendEmail, reason};
191
+ }
192
+
193
+ /**
194
+ * @returns {Promise<Milestone>}
195
+ */
196
+ async #runARRQueries() {
197
+ // Fetch the current data from queries
198
+ const currentARR = await this.#queries.getARR();
199
+ const defaultCurrency = await this.#getDefaultCurrency();
200
+
201
+ // Check the definitions in the milestonesConfig
202
+ const arrMilestoneSettings = this.#milestonesConfig.arr;
203
+ const supportedCurrencies = arrMilestoneSettings.map(setting => setting.currency);
204
+
205
+ // First check the currency matches
206
+ if (currentARR.length) {
207
+ const currentARRForCurrency = currentARR.filter(arr => arr.currency === defaultCurrency && supportedCurrencies.includes(defaultCurrency))[0];
208
+ const milestonesForCurrency = arrMilestoneSettings.filter(milestoneSetting => milestoneSetting.currency === defaultCurrency)[0];
209
+
210
+ if (milestonesForCurrency && currentARRForCurrency) {
211
+ // get all milestones that have been achieved
212
+ const achievedMilestones = this.#getMatchedMilestones(milestonesForCurrency.values, currentARRForCurrency.arr);
213
+
214
+ // check for previously achieved milestones. We do not send an email when no
215
+ // previous milestones exist
216
+ const allMilestonesForCurrency = await this.#repository.getAllByType('arr', defaultCurrency);
217
+ const isInitialRun = !allMilestonesForCurrency || allMilestonesForCurrency?.length === 0;
218
+ const highestAchievedMilestone = Math.max(...achievedMilestones);
219
+
220
+ if (achievedMilestones && achievedMilestones.length) {
221
+ for await (const milestone of achievedMilestones) {
222
+ // Fetch the latest milestone for this currency
223
+ const latestMilestone = await this.#getLatestArrMilestone(defaultCurrency);
224
+
225
+ // Ensure the milestone doesn't already exist
226
+ const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'arr', currency: defaultCurrency});
227
+
228
+ if (!milestoneExists) {
229
+ if (isInitialRun) {
230
+ // No milestones have been saved yet, don't send an email
231
+ // for the first initial run
232
+ const meta = {
233
+ currentValue: currentARRForCurrency.arr,
234
+ reason: 'initial'
235
+ };
236
+ await this.#saveMileStoneWithoutEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta});
237
+ } else if ((latestMilestone && milestone <= latestMilestone?.value) || milestone < highestAchievedMilestone) {
238
+ // The highest achieved milestone is higher than the current on hand.
239
+ // Do not send an email, but save it.
240
+ const meta = {
241
+ currentValue: currentARRForCurrency.arr,
242
+ reason: 'skipped'
243
+ };
244
+ await this.#saveMileStoneWithoutEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta});
245
+ } else if ((!latestMilestone || milestone > latestMilestone.value)) {
246
+ const meta = {
247
+ currentValue: currentARRForCurrency.arr
248
+ };
249
+ await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta});
250
+ }
251
+ }
252
+ }
253
+ }
254
+ return await this.#getLatestArrMilestone(defaultCurrency);
255
+ }
256
+ }
257
+ }
258
+
259
+ /**
260
+ * @returns {Promise<Milestone>}
261
+ */
262
+ async #runMemberQueries() {
263
+ // Fetch the current data
264
+ const membersCount = await this.#queries.getMembersCount();
265
+
266
+ // Check the definitions in the milestonesConfig
267
+ const membersMilestones = this.#milestonesConfig.members;
268
+
269
+ // get the closest milestone we're over now
270
+ let achievedMilestones = this.#getMatchedMilestones(membersMilestones, membersCount);
271
+
272
+ // check for previously achieved milestones. We do not send an email when no
273
+ // previous milestones exist
274
+ const allMembersMilestones = await this.#repository.getAllByType('members', null);
275
+ const isInitialRun = !allMembersMilestones || allMembersMilestones?.length === 0;
276
+ const highestAchievedMilestone = Math.max(...achievedMilestones);
277
+
278
+ if (achievedMilestones && achievedMilestones.length) {
279
+ for await (const milestone of achievedMilestones) {
280
+ // Fetch the latest achieved Members milestones
281
+ const latestMembersMilestone = await this.#getLatestMembersCountMilestone();
282
+
283
+ // Ensure the milestone doesn't already exist
284
+ const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'members', currency: null});
285
+
286
+ if (!milestoneExists) {
287
+ if (isInitialRun) {
288
+ // No milestones have been saved yet, don't send an email
289
+ // for the first initial run
290
+ const meta = {
291
+ currentValue: membersCount,
292
+ reason: 'initial'
293
+ };
294
+ await this.#saveMileStoneWithoutEmail({value: milestone, type: 'members', meta});
295
+ } else if ((latestMembersMilestone && milestone <= latestMembersMilestone?.value) || milestone < highestAchievedMilestone) {
296
+ // The highest achieved milestone is higher than the current on hand.
297
+ // Do not send an email, but save it.
298
+ const meta = {
299
+ currentValue: membersCount,
300
+ reason: 'skipped'
301
+ };
302
+ await this.#saveMileStoneWithoutEmail({value: milestone, type: 'members', meta});
303
+ } else if ((!latestMembersMilestone || milestone > latestMembersMilestone.value)) {
304
+ const meta = {
305
+ currentValue: membersCount
306
+ };
307
+ await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members', meta});
308
+ }
309
+ }
310
+ }
311
+ return await this.#getLatestMembersCountMilestone();
312
+ }
313
+ }
314
+
315
+ /**
316
+ * @param {'arr'|'members'} type
317
+ *
318
+ * @returns {Promise<Milestone>}
319
+ */
320
+ async checkMilestones(type) {
321
+ if (type === 'arr') {
322
+ return await this.#runARRQueries();
323
+ }
324
+
325
+ return await this.#runMemberQueries();
326
+ }
327
+ };