Ghost/ghost/members-api/lib/services/MemberBREADService.js

434 lines
16 KiB
JavaScript
Raw Normal View History

const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const tpl = require('@tryghost/tpl');
const moment = require('moment');
const messages = {
stripeNotConnected: 'Missing Stripe connection.',
memberAlreadyExists: 'Member already exists.'
};
/**
* @typedef {object} ILabsService
* @prop {(key: string) => boolean} isSet
*/
/**
* @typedef {object} IEmailService
* @prop {(data: {email: string, requestedType: string}) => Promise<any>} sendEmailWithMagicLink
*/
/**
* @typedef {object} IStripeService
* @prop {boolean} configured
*/
/**
* @typedef {import('@tryghost/members-offers/lib/application/OfferMapper').OfferDTO} OfferDTO
*/
module.exports = class MemberBREADService {
/**
* @param {object} deps
* @param {import('../repositories/MemberRepository')} deps.memberRepository
* @param {import('@tryghost/members-offers/lib/application/OffersAPI')} deps.offersAPI
* @param {ILabsService} deps.labsService
* @param {IEmailService} deps.emailService
* @param {IStripeService} deps.stripeService
* @param {import('@tryghost/member-attribution/lib/service')} deps.memberAttributionService
* @param {import('@tryghost/email-suppression-list/lib/email-suppression-list').IEmailSuppressionList} deps.emailSuppressionList
*/
constructor({memberRepository, labsService, emailService, stripeService, offersAPI, memberAttributionService, emailSuppressionList}) {
this.offersAPI = offersAPI;
/** @private */
this.memberRepository = memberRepository;
/** @private */
this.labsService = labsService;
/** @private */
this.emailService = emailService;
/** @private */
this.stripeService = stripeService;
/** @private */
this.memberAttributionService = memberAttributionService;
/** @private */
this.emailSuppressionList = emailSuppressionList;
}
/**
* @private
* Adds missing complimentary subscriptions to a member and makes sure the tier of all subscriptions is set correctly.
*/
attachSubscriptionsToMember(member) {
if (!member.products || !Array.isArray(member.products)) {
return member;
}
const subscriptionProducts = (member.subscriptions || [])
.filter(sub => this.memberRepository.isActiveSubscriptionStatus(sub.status))
.map(sub => sub.price.product.product_id);
// Remove incomplete subscriptions from the API
member.subscriptions = member.subscriptions.filter(sub => sub.status !== 'incomplete' && sub.status !== 'incomplete_expired');
for (const product of member.products) {
if (!subscriptionProducts.includes(product.id)) {
const productAddEvent = member.productEvents.find(event => event.product_id === product.id);
let startDate;
if (!productAddEvent || productAddEvent.action !== 'added') {
startDate = moment();
} else {
startDate = moment(productAddEvent.created_at);
}
member.subscriptions.push({
id: '',
tier: product,
customer: {
id: '',
name: member.name,
email: member.email
},
plan: {
id: '',
nickname: 'Complimentary',
interval: 'year',
currency: 'USD',
amount: 0
},
status: 'active',
start_date: startDate,
default_payment_card_last4: '****',
cancel_at_period_end: false,
cancellation_reason: null,
current_period_end: moment(product.expiry_at),
price: {
id: '',
price_id: '',
nickname: 'Complimentary',
amount: 0,
interval: 'year',
type: 'recurring',
currency: 'USD',
product: {
id: '',
product_id: product.id
}
}
});
}
}
for (const subscription of member.subscriptions) {
if (!subscription.tier) {
subscription.tier = member.products.find(product => product.id === subscription.price.product.product_id);
}
}
}
/**
* @private Builds a map between subscriptions and their offer representation (from OfferMapper)
* @returns {Promise<Map<string, OfferDTO>>}
*/
async fetchSubscriptionOffers(subscriptions) {
const fetchedOffers = new Map();
const subscriptionOffers = new Map();
try {
for (const subscriptionModel of subscriptions) {
const offerId = subscriptionModel.get('offer_id');
if (!offerId) {
continue;
}
let offer = fetchedOffers.get(offerId);
if (!offer) {
offer = await this.offersAPI.getOffer({id: offerId});
fetchedOffers.set(offerId, offer);
}
subscriptionOffers.set(subscriptionModel.get('subscription_id'), offer);
}
} catch (e) {
logging.error(`Failed to load offers for subscriptions - ${subscriptions.map(s => s.id).join(', ')}.`);
logging.error(e);
}
return subscriptionOffers;
}
/**
* @private
* @param {Object} member JSON serialized member
* @param {Map<string, OfferDTO>} subscriptionOffers result from fetchSubscriptionOffers
*/
attachOffersToSubscriptions(member, subscriptionOffers) {
member.subscriptions = member.subscriptions.map((subscription) => {
const offer = subscriptionOffers.get(subscription.id);
if (offer) {
subscription.offer = offer;
} else {
subscription.offer = null;
}
return subscription;
});
}
/**
* @private
* Adds missing complimentary subscriptions to a member and makes sure the tier of all subscriptions is set correctly.
*/
async attachAttributionsToMember(member, subscriptionIdMap) {
// Created attribution
member.attribution = await this.memberAttributionService.getMemberCreatedAttribution(member.id);
// Subscriptions attributions
for (const subscription of member.subscriptions) {
if (!subscription.id) {
continue;
}
// Convert stripe ID to database id
const id = subscriptionIdMap.get(subscription.id);
if (!id) {
continue;
}
subscription.attribution = await this.memberAttributionService.getSubscriptionCreatedAttribution(id);
}
}
async read(data, options = {}) {
const defaultWithRelated = [
'labels',
'stripeSubscriptions',
'stripeSubscriptions.customer',
'stripeSubscriptions.stripePrice',
'stripeSubscriptions.stripePrice.stripeProduct',
'stripeSubscriptions.stripePrice.stripeProduct.product',
'products',
'newsletters'
];
const withRelated = new Set((options.withRelated || []).concat(defaultWithRelated));
if (!withRelated.has('productEvents')) {
withRelated.add('productEvents');
}
if (withRelated.has('email_recipients')) {
withRelated.add('email_recipients.email');
}
const model = await this.memberRepository.get(data, {
...options,
withRelated: Array.from(withRelated)
});
if (!model) {
return null;
}
// We need to know the real IDs for each subscription to fetch the member attribution
const subscriptionIdMap = new Map();
for (const subscription of model.related('stripeSubscriptions')) {
subscriptionIdMap.set(subscription.get('subscription_id'), subscription.id);
}
const member = model.toJSON(options);
member.subscriptions = member.subscriptions.filter(sub => !!sub.price);
this.attachSubscriptionsToMember(member);
this.attachOffersToSubscriptions(member, await this.fetchSubscriptionOffers(model.related('stripeSubscriptions')));
await this.attachAttributionsToMember(member, subscriptionIdMap);
const suppressionData = await this.emailSuppressionList.getSuppressionData(member.email);
member.email_suppression = {
suppressed: suppressionData.suppressed || !!model.get('email_disabled'),
info: suppressionData.info
};
return member;
}
async add(data, options) {
if (!this.stripeService.configured && (data.comped || data.stripe_customer_id)) {
const property = data.comped ? 'comped' : 'stripe_customer_id';
throw new errors.ValidationError({
message: tpl(messages.stripeNotConnected),
context: 'Attempting to import members with Stripe data when there is no Stripe account connected.',
help: 'You need to connect to Stripe to import Stripe customers. ',
property
});
}
let model;
try {
const attribution = await this.memberAttributionService.getAttributionFromContext(options?.context);
if (attribution) {
data.attribution = attribution;
}
model = await this.memberRepository.create(data, options);
} catch (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
throw new errors.ValidationError({
message: tpl(messages.memberAlreadyExists),
context: 'Attempting to add member with existing email address',
property: 'email'
});
}
throw error;
}
const sharedOptions = options.transacting ? {
transacting: options.transacting
} : {};
try {
if (data.stripe_customer_id) {
await this.memberRepository.linkStripeCustomer({
customer_id: data.stripe_customer_id,
member_id: model.id
}, sharedOptions);
}
} catch (error) {
const isStripeLinkingError = error.message && (error.message.match(/customer|plan|subscription/g));
if (isStripeLinkingError) {
if (error.message.indexOf('customer') && error.code === 'resource_missing') {
error.message = `Member not imported. ${error.message}`;
error.context = 'Missing Stripe Customer';
error.help = 'Make sure you\'re connected to the correct Stripe Account';
}
await this.memberRepository.destroy({
id: model.id
}, options);
}
throw error;
}
if (options.send_email) {
await this.emailService.sendEmailWithMagicLink({
email: model.get('email'), requestedType: options.email_type
});
}
if (data.comped) {
await this.memberRepository.setComplimentarySubscription(model, options);
}
return this.read({id: model.id}, options);
}
async edit(data, options) {
delete data.last_seen_at;
let model;
try {
// Update email_disabled based on whether the new email is suppressed
if (data.email) {
const isSuppressed = (await this.emailSuppressionList.getSuppressionData(data.email))?.suppressed;
data.email_disabled = !!isSuppressed;
}
model = await this.memberRepository.update(data, options);
} catch (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
throw new errors.ValidationError({
message: tpl(messages.memberAlreadyExists),
context: 'Attempting to edit member with existing email address',
property: 'email'
});
}
throw error;
}
if (this.stripeService.configured) {
const hasCompedSubscription = !!model.related('stripeSubscriptions').find(sub => sub.get('plan_nickname') === 'Complimentary' && sub.get('status') === 'active');
if (typeof data.comped === 'boolean') {
if (data.comped && !hasCompedSubscription) {
await this.memberRepository.setComplimentarySubscription(model, {
context: options.context,
transacting: options.transacting
});
} else if (!(data.comped) && hasCompedSubscription) {
await this.memberRepository.cancelComplimentarySubscription(model, {
context: options.context,
transacting: options.transacting
});
}
}
}
return this.read({id: model.id}, options);
}
async logout(options) {
await this.memberRepository.cycleTransientId(options);
}
async browse(options) {
const defaultWithRelated = [
'labels',
'stripeSubscriptions',
'stripeSubscriptions.customer',
'stripeSubscriptions.stripePrice',
'stripeSubscriptions.stripePrice.stripeProduct',
'stripeSubscriptions.stripePrice.stripeProduct.product',
'products',
'newsletters'
];
const originalWithRelated = options.withRelated || [];
const withRelated = new Set((originalWithRelated).concat(defaultWithRelated));
if (!withRelated.has('productEvents')) {
withRelated.add('productEvents');
}
if (withRelated.has('email_recipients')) {
withRelated.add('email_recipients.email');
}
//option param to skip distinct from count query, distinct adds a lot of latency and in this case the result set will always be unique.
options.useBasicCount = true;
const page = await this.memberRepository.list({
...options,
withRelated: Array.from(withRelated)
});
if (!page) {
return null;
}
const subscriptions = page.data.flatMap(model => model.related('stripeSubscriptions').slice());
const offerMap = await this.fetchSubscriptionOffers(subscriptions);
const bulkSuppressionData = await this.emailSuppressionList.getBulkSuppressionData(page.data.map(member => member.get('email')));
const data = page.data.map((model, index) => {
const member = model.toJSON(options);
member.subscriptions = member.subscriptions.filter(sub => !!sub.price);
this.attachSubscriptionsToMember(member);
this.attachOffersToSubscriptions(member, offerMap);
if (!originalWithRelated.includes('products')) {
delete member.products;
}
member.email_suppression = {
suppressed: bulkSuppressionData[index].suppressed || !!model.get('email_disabled'),
info: bulkSuppressionData[index].info
};
return member;
});
return {
data,
meta: page.meta
};
}
};