ghost 5.117.0 → 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.
- package/components/{tryghost-api-framework-5.117.0.tgz → tryghost-api-framework-5.118.0.tgz} +0 -0
- package/components/tryghost-constants-5.118.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.118.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.118.0.tgz +0 -0
- package/components/tryghost-domain-events-5.118.0.tgz +0 -0
- package/components/tryghost-donations-5.118.0.tgz +0 -0
- package/components/{tryghost-email-addresses-5.117.0.tgz → tryghost-email-addresses-5.118.0.tgz} +0 -0
- package/components/{tryghost-email-service-5.117.0.tgz → tryghost-email-service-5.118.0.tgz} +0 -0
- package/components/{tryghost-email-suppression-list-5.117.0.tgz → tryghost-email-suppression-list-5.118.0.tgz} +0 -0
- package/components/tryghost-html-to-plaintext-5.118.0.tgz +0 -0
- package/components/tryghost-i18n-5.118.0.tgz +0 -0
- package/components/{tryghost-job-manager-5.117.0.tgz → tryghost-job-manager-5.118.0.tgz} +0 -0
- package/components/tryghost-link-replacer-5.118.0.tgz +0 -0
- package/components/{tryghost-magic-link-5.117.0.tgz → tryghost-magic-link-5.118.0.tgz} +0 -0
- package/components/{tryghost-member-attribution-5.117.0.tgz → tryghost-member-attribution-5.118.0.tgz} +0 -0
- package/components/tryghost-member-events-5.118.0.tgz +0 -0
- package/components/{tryghost-members-csv-5.117.0.tgz → tryghost-members-csv-5.118.0.tgz} +0 -0
- package/components/{tryghost-members-offers-5.117.0.tgz → tryghost-members-offers-5.118.0.tgz} +0 -0
- package/components/tryghost-mw-error-handler-5.118.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.118.0.tgz +0 -0
- package/components/tryghost-post-events-5.118.0.tgz +0 -0
- package/components/tryghost-post-revisions-5.118.0.tgz +0 -0
- package/components/{tryghost-posts-service-5.117.0.tgz → tryghost-posts-service-5.118.0.tgz} +0 -0
- package/components/tryghost-prometheus-metrics-5.118.0.tgz +0 -0
- package/components/tryghost-security-5.118.0.tgz +0 -0
- package/components/tryghost-tiers-5.118.0.tgz +0 -0
- package/components/tryghost-webmentions-5.118.0.tgz +0 -0
- package/content/themes/casper/LICENSE +1 -1
- package/content/themes/casper/README.md +1 -1
- package/content/themes/casper/assets/built/screen.css +1 -1
- package/content/themes/casper/assets/built/screen.css.map +1 -1
- package/content/themes/casper/assets/css/screen.css +1 -1
- package/content/themes/casper/author.hbs +23 -2
- package/content/themes/casper/package.json +2 -2
- package/content/themes/casper/partials/icons/bluesky.hbs +3 -0
- package/content/themes/casper/partials/icons/instagram.hbs +5 -0
- package/content/themes/casper/partials/icons/linkedin.hbs +3 -0
- package/content/themes/casper/partials/icons/mastodon.hbs +3 -0
- package/content/themes/casper/partials/icons/threads.hbs +3 -0
- package/content/themes/casper/partials/icons/tiktok.hbs +3 -0
- package/content/themes/casper/partials/icons/twitter.hbs +3 -1
- package/content/themes/casper/partials/icons/youtube.hbs +3 -0
- package/content/themes/source/LICENSE +1 -1
- package/content/themes/source/README.md +1 -1
- package/content/themes/source/assets/built/screen.css +1 -1
- package/content/themes/source/assets/built/screen.css.map +1 -1
- package/content/themes/source/assets/css/screen.css +7 -12
- package/content/themes/source/author.hbs +24 -3
- package/content/themes/source/package.json +2 -2
- package/content/themes/source/partials/feature-image.hbs +2 -2
- package/content/themes/source/partials/icons/bluesky.hbs +3 -0
- package/content/themes/source/partials/icons/instagram.hbs +5 -0
- package/content/themes/source/partials/icons/linkedin.hbs +3 -0
- package/content/themes/source/partials/icons/mastodon.hbs +3 -0
- package/content/themes/source/partials/icons/threads.hbs +3 -0
- package/content/themes/source/partials/icons/tiktok.hbs +3 -0
- package/content/themes/source/partials/icons/youtube.hbs +3 -0
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +31773 -26586
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-3bc05d1b.mjs → CodeEditorView-1143c509.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-c5623145.mjs → index-19ebc8ad.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-b2cdc747.mjs → index-ac104f42.mjs} +2559 -2551
- package/core/built/admin/assets/admin-x-settings/{modals-fd7bc70c.mjs → modals-994901ee.mjs} +6680 -6165
- package/core/built/admin/assets/{chunk.524.f4d7526780f546c5fc0b.js → chunk.524.5710919eb507b9a81166.js} +6 -6
- package/core/built/admin/assets/{chunk.582.869c66dfbfa68412de07.js → chunk.582.c8cb99b85cfa13fc7df1.js} +8 -8
- package/core/built/admin/assets/{ghost-45186e4f079c9fdd8f42dfbfb93d3344.js → ghost-cd90a28b214ee800a007bb62cd45e6e6.js} +803 -800
- package/core/built/admin/assets/posts/posts.js +11542 -11341
- package/core/built/admin/assets/stats/stats.js +37309 -31520
- package/core/built/admin/index.html +3 -3
- package/core/frontend/helpers/social_url.js +31 -0
- package/core/server/api/endpoints/users.js +7 -0
- package/core/server/api/endpoints/utils/serializers/output/config.js +2 -1
- package/core/server/services/auth/session/index.js +5 -2
- package/core/server/services/auth/session/session-service.js +5 -4
- package/core/server/services/members/api.js +2 -2
- package/core/server/services/members/members-api/controllers/MemberController.js +214 -0
- package/core/server/services/members/members-api/controllers/RouterController.js +667 -0
- package/core/server/services/members/members-api/controllers/WellKnownController.js +46 -0
- package/core/server/services/members/members-api/members-api.js +404 -0
- package/core/server/services/members/members-api/repositories/EventRepository.js +984 -0
- package/core/server/services/members/members-api/repositories/MemberRepository.js +1739 -0
- package/core/server/services/members/members-api/repositories/ProductRepository.js +662 -0
- package/core/server/services/members/members-api/services/GeolocationService.js +23 -0
- package/core/server/services/members/members-api/services/MemberBREADService.js +444 -0
- package/core/server/services/members/members-api/services/PaymentsService.js +522 -0
- package/core/server/services/members/members-api/services/TokenService.js +54 -0
- package/core/server/services/milestones/BookshelfMilestoneRepository.js +8 -9
- package/core/server/services/milestones/InMemoryMilestoneRepository.js +119 -0
- package/core/server/services/milestones/Milestone.js +231 -0
- package/core/server/services/milestones/MilestoneCreatedEvent.js +22 -0
- package/core/server/services/milestones/MilestonesService.js +327 -0
- package/core/server/services/milestones/service.js +2 -2
- package/core/server/services/newsletters/index.js +1 -1
- package/core/server/services/public-config/config.js +2 -1
- package/core/server/services/settings/settings-service.js +1 -1
- package/core/server/services/slack-notifications/SlackNotifications.js +1 -1
- package/core/server/services/slack-notifications/SlackNotificationsService.js +2 -2
- package/core/server/services/staff/StaffService.js +1 -1
- package/core/shared/config/defaults.json +3 -0
- package/core/shared/config/env/config.testing-mysql.json +3 -0
- package/core/shared/config/env/config.testing.json +3 -0
- package/core/shared/labs.js +3 -4
- package/package.json +63 -63
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +86 -44
- package/components/tryghost-constants-5.117.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.117.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.117.0.tgz +0 -0
- package/components/tryghost-domain-events-5.117.0.tgz +0 -0
- package/components/tryghost-donations-5.117.0.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.117.0.tgz +0 -0
- package/components/tryghost-i18n-5.117.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.117.0.tgz +0 -0
- package/components/tryghost-member-events-5.117.0.tgz +0 -0
- package/components/tryghost-members-api-5.117.0.tgz +0 -0
- package/components/tryghost-milestones-5.117.0.tgz +0 -0
- package/components/tryghost-mw-error-handler-5.117.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.117.0.tgz +0 -0
- package/components/tryghost-post-events-5.117.0.tgz +0 -0
- package/components/tryghost-post-revisions-5.117.0.tgz +0 -0
- package/components/tryghost-prometheus-metrics-5.117.0.tgz +0 -0
- package/components/tryghost-security-5.117.0.tgz +0 -0
- package/components/tryghost-tiers-5.117.0.tgz +0 -0
- package/components/tryghost-webmentions-5.117.0.tgz +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
|
+
};
|