2f57e95a5d
refs https://www.notion.so/ghost/Marketing-Milestone-email-campaigns-1d2c9dee3cfa4029863edb16092ad5c4?pvs=4 - Added a `slack-notifications` repository which handles sending Slack messages to a URL as defined in our Ghost(Pro) config (also includes a global switch to disable the feature if needed) and listens to `MilestoneCreatedEvents`. - Added a `slack-notification` service which listens to the events on boot. - In order to have access to further information such as the reason why a Milestone email hasn't been sent, or the current ARR or Member value as comparison to the achieved milestone, I added a `meta` object to the `MilestoneCreatedEvent` which then gets accessible by the event subscriber. This avoid doing further requests to the DB as we need to have this information in relation to the event occurred. --------- Co-authored-by: Fabien "egg" O'Carroll <fabien@allou.is>
232 lines
4.9 KiB
JavaScript
232 lines
4.9 KiB
JavaScript
const ObjectID = require('bson-objectid').default;
|
|
const {ValidationError} = require('@tryghost/errors');
|
|
const MilestoneCreatedEvent = require('./MilestoneCreatedEvent');
|
|
|
|
module.exports = class Milestone {
|
|
/** @type {Array} */
|
|
events = [];
|
|
|
|
/**
|
|
* @type {ObjectID}
|
|
*/
|
|
#id;
|
|
get id() {
|
|
return this.#id;
|
|
}
|
|
|
|
/**
|
|
* @type {'arr'|'members'}
|
|
*/
|
|
#type;
|
|
get type() {
|
|
return this.#type;
|
|
}
|
|
|
|
/** @type {number} */
|
|
#value;
|
|
get value() {
|
|
return this.#value;
|
|
}
|
|
|
|
/** @type {string} */
|
|
#currency;
|
|
get currency() {
|
|
return this.#currency;
|
|
}
|
|
|
|
/** @type {Date} */
|
|
#createdAt;
|
|
get createdAt() {
|
|
return this.#createdAt;
|
|
}
|
|
|
|
/** @type {Date|null} */
|
|
#emailSentAt;
|
|
get emailSentAt() {
|
|
return this.#emailSentAt;
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
id: this.id,
|
|
name: this.name,
|
|
type: this.type,
|
|
value: this.value,
|
|
currency: this.currency,
|
|
createdAt: this.createdAt,
|
|
emailSentAt: this.emailSentAt
|
|
};
|
|
}
|
|
|
|
/** @private */
|
|
constructor(data) {
|
|
this.#id = data.id;
|
|
this.#type = data.type;
|
|
this.#value = data.value;
|
|
this.#currency = data.currency;
|
|
this.#createdAt = data.createdAt;
|
|
this.#emailSentAt = data.emailSentAt;
|
|
}
|
|
|
|
/**
|
|
* @returns {string}
|
|
*/
|
|
get name() {
|
|
if (this.type === 'arr') {
|
|
return `arr-${this.value}-${this.currency}`;
|
|
}
|
|
return `members-${this.value}`;
|
|
}
|
|
|
|
/**
|
|
* @param {any} data
|
|
* @returns {Promise<Milestone>}
|
|
*/
|
|
static async create(data) {
|
|
/** @type ObjectID */
|
|
let id;
|
|
let isNew = false;
|
|
if (!data.id) {
|
|
isNew = true;
|
|
id = new ObjectID();
|
|
} else if (typeof data.id === 'string') {
|
|
id = ObjectID.createFromHexString(data.id);
|
|
} else if (data.id instanceof ObjectID) {
|
|
id = data.id;
|
|
} else {
|
|
throw new ValidationError({
|
|
message: 'Invalid ID provided for Milestone'
|
|
});
|
|
}
|
|
|
|
const type = validateType(data.type);
|
|
const currency = validateCurrency(type, data?.currency);
|
|
const value = validateValue(data.value);
|
|
const name = validateName(data.name, value, type, currency);
|
|
const emailSentAt = validateEmailSentAt(data);
|
|
|
|
/** @type Date */
|
|
let createdAt;
|
|
if (data.createdAt instanceof Date) {
|
|
createdAt = data.createdAt;
|
|
} else if (data.createdAt) {
|
|
createdAt = new Date(data.createdAt);
|
|
if (isNaN(createdAt.valueOf())) {
|
|
throw new ValidationError({
|
|
message: 'Invalid Date'
|
|
});
|
|
}
|
|
} else {
|
|
createdAt = new Date();
|
|
}
|
|
|
|
const milestone = new Milestone({
|
|
id,
|
|
name,
|
|
type,
|
|
value,
|
|
currency,
|
|
createdAt,
|
|
emailSentAt
|
|
});
|
|
|
|
if (isNew) {
|
|
milestone.events.push(MilestoneCreatedEvent.create({milestone, meta: data?.meta}));
|
|
}
|
|
|
|
return milestone;
|
|
}
|
|
};
|
|
|
|
/**
|
|
*
|
|
* @param {number|null} value
|
|
*
|
|
* @returns {number}
|
|
*/
|
|
function validateValue(value) {
|
|
if (!value || typeof value !== 'number' || value === 0) {
|
|
throw new ValidationError({
|
|
message: 'Invalid value'
|
|
});
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {unknown} type
|
|
*
|
|
* @returns {'arr'|'members'}
|
|
*/
|
|
function validateType(type) {
|
|
if (type === 'arr') {
|
|
return 'arr';
|
|
}
|
|
|
|
return 'members';
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {'arr'|'members'} type
|
|
* @param {string|null} currency
|
|
*
|
|
* @returns {string}
|
|
*/
|
|
function validateCurrency(type, currency) {
|
|
if (type === 'members') {
|
|
return null;
|
|
}
|
|
|
|
if (!currency || (currency && typeof currency !== 'string' || currency.length > 3)) {
|
|
return 'usd';
|
|
}
|
|
|
|
return currency;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} name
|
|
* @param {number} value
|
|
* @param {'arr'|'members'} type
|
|
* @param {string|null} currency
|
|
*
|
|
* @returns {string}
|
|
*/
|
|
function validateName(name, value, type, currency) {
|
|
if (!name || !name.match(/(arr|members)-\d*/i)) {
|
|
return type === 'arr' ? `${type}-${value}-${currency}` : `${type}-${value}`;
|
|
}
|
|
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {object} data
|
|
* @param {Date|null} data.emailSentAt
|
|
*
|
|
* @returns {Date|null}
|
|
*/
|
|
function validateEmailSentAt(data) {
|
|
let emailSentAt;
|
|
if (data.emailSentAt instanceof Date) {
|
|
emailSentAt = data.emailSentAt;
|
|
} else if (data.emailSentAt) {
|
|
emailSentAt = new Date(data.emailSentAt);
|
|
if (isNaN(emailSentAt.valueOf())) {
|
|
throw new ValidationError({
|
|
message: 'Invalid Date'
|
|
});
|
|
}
|
|
} else {
|
|
emailSentAt = null;
|
|
}
|
|
|
|
return emailSentAt;
|
|
}
|
|
|