Ghost/ghost/milestones/lib/Milestone.js
Aileen Booker 2f57e95a5d
Slack notifications service for Milestones behind flag (#16281)
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>
2023-02-17 12:59:18 +02:00

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;
}