mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 10:21:36 +03:00
33f26fbf32
refs https://github.com/TryGhost/Team/issues/616 We need a way to assign Products to Members via a Subscription, and we've followed the same pattern as the editSubscription method for the Members API controller, which acts upon Subscriptions as a nested resource. Subscriptions now are linked to products, and we've included those links by default in the Member Admin API as we already include subscriptions by default, and Products are now a core part of the Members feature-set.
575 lines
19 KiB
JavaScript
575 lines
19 KiB
JavaScript
// NOTE: We must not cache references to membersService.api
|
|
// as it is a getter and may change during runtime.
|
|
const Promise = require('bluebird');
|
|
const moment = require('moment-timezone');
|
|
const errors = require('@tryghost/errors');
|
|
const models = require('../../models');
|
|
const membersService = require('../../services/members');
|
|
|
|
const settingsCache = require('../../services/settings/cache');
|
|
const {i18n} = require('../../lib/common');
|
|
const _ = require('lodash');
|
|
|
|
const allowedIncludes = ['email_recipients', 'products'];
|
|
|
|
module.exports = {
|
|
docName: 'members',
|
|
|
|
hasActiveStripeSubscriptions: {
|
|
permissions: {
|
|
method: 'browse'
|
|
},
|
|
async query() {
|
|
const hasActiveStripeSubscriptions = await membersService.api.hasActiveStripeSubscriptions();
|
|
return {
|
|
hasActiveStripeSubscriptions
|
|
};
|
|
}
|
|
},
|
|
|
|
browse: {
|
|
options: [
|
|
'limit',
|
|
'fields',
|
|
'filter',
|
|
'order',
|
|
'debug',
|
|
'page',
|
|
'search'
|
|
],
|
|
permissions: true,
|
|
validation: {},
|
|
async query(frame) {
|
|
frame.options.withRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct'];
|
|
const page = await membersService.api.members.list(frame.options);
|
|
|
|
return page;
|
|
}
|
|
},
|
|
|
|
read: {
|
|
options: [
|
|
'include'
|
|
],
|
|
headers: {},
|
|
data: [
|
|
'id',
|
|
'email'
|
|
],
|
|
validation: {
|
|
options: {
|
|
include: {
|
|
values: allowedIncludes
|
|
}
|
|
}
|
|
},
|
|
permissions: true,
|
|
async query(frame) {
|
|
const defaultWithRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct'];
|
|
|
|
if (!frame.options.withRelated) {
|
|
frame.options.withRelated = defaultWithRelated;
|
|
} else {
|
|
frame.options.withRelated = frame.options.withRelated.concat(defaultWithRelated);
|
|
}
|
|
|
|
if (frame.options.withRelated.includes('email_recipients')) {
|
|
frame.options.withRelated.push('email_recipients.email');
|
|
}
|
|
|
|
let model = await membersService.api.members.get(frame.data, frame.options);
|
|
|
|
if (!model) {
|
|
throw new errors.NotFoundError({
|
|
message: i18n.t('errors.api.members.memberNotFound')
|
|
});
|
|
}
|
|
|
|
return model;
|
|
}
|
|
},
|
|
|
|
add: {
|
|
statusCode: 201,
|
|
headers: {},
|
|
options: [
|
|
'send_email',
|
|
'email_type'
|
|
],
|
|
validation: {
|
|
data: {
|
|
email: {required: true}
|
|
},
|
|
options: {
|
|
email_type: {
|
|
values: ['signin', 'signup', 'subscribe']
|
|
}
|
|
}
|
|
},
|
|
permissions: true,
|
|
async query(frame) {
|
|
let member;
|
|
frame.options.withRelated = ['stripeSubscriptions', 'products', 'labels', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct'];
|
|
try {
|
|
if (!membersService.config.isStripeConnected()
|
|
&& (frame.data.members[0].stripe_customer_id || frame.data.members[0].comped)) {
|
|
const property = frame.data.members[0].comped ? 'comped' : 'stripe_customer_id';
|
|
|
|
throw new errors.ValidationError({
|
|
message: i18n.t('errors.api.members.stripeNotConnected.message'),
|
|
context: i18n.t('errors.api.members.stripeNotConnected.context'),
|
|
help: i18n.t('errors.api.members.stripeNotConnected.help'),
|
|
property
|
|
});
|
|
}
|
|
|
|
member = await membersService.api.members.create(frame.data.members[0], frame.options);
|
|
|
|
if (frame.data.members[0].stripe_customer_id) {
|
|
await membersService.api.members.linkStripeCustomer(frame.data.members[0].stripe_customer_id, member);
|
|
}
|
|
|
|
if (frame.data.members[0].comped) {
|
|
await membersService.api.members.setComplimentarySubscription(member);
|
|
}
|
|
|
|
if (frame.options.send_email) {
|
|
await membersService.api.sendEmailWithMagicLink({email: member.get('email'), requestedType: frame.options.email_type});
|
|
}
|
|
|
|
return member;
|
|
} catch (error) {
|
|
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
|
|
throw new errors.ValidationError({
|
|
message: i18n.t('errors.models.member.memberAlreadyExists.message'),
|
|
context: i18n.t('errors.models.member.memberAlreadyExists.context', {
|
|
action: 'add'
|
|
})
|
|
});
|
|
}
|
|
|
|
// NOTE: failed to link Stripe customer/plan/subscription or have thrown custom Stripe connection error.
|
|
// It's a bit ugly doing regex matching to detect errors, but it's the easiest way that works without
|
|
// introducing additional logic/data format into current error handling
|
|
const isStripeLinkingError = error.message && (error.message.match(/customer|plan|subscription/g));
|
|
if (member && isStripeLinkingError) {
|
|
if (error.message.indexOf('customer') && error.code === 'resource_missing') {
|
|
error.message = `Member not imported. ${error.message}`;
|
|
error.context = i18n.t('errors.api.members.stripeCustomerNotFound.context');
|
|
error.help = i18n.t('errors.api.members.stripeCustomerNotFound.help');
|
|
}
|
|
|
|
await membersService.api.members.destroy({
|
|
id: member.get('id')
|
|
}, frame.options);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
},
|
|
|
|
edit: {
|
|
statusCode: 200,
|
|
headers: {},
|
|
options: [
|
|
'id'
|
|
],
|
|
validation: {
|
|
options: {
|
|
id: {
|
|
required: true
|
|
}
|
|
}
|
|
},
|
|
permissions: true,
|
|
async query(frame) {
|
|
try {
|
|
frame.options.withRelated = ['stripeSubscriptions', 'products', 'labels', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct'];
|
|
const member = await membersService.api.members.update(frame.data.members[0], frame.options);
|
|
|
|
const hasCompedSubscription = !!member.related('stripeSubscriptions').find(sub => sub.get('plan_nickname') === 'Complimentary' && sub.get('status') === 'active');
|
|
|
|
if (typeof frame.data.members[0].comped === 'boolean') {
|
|
if (frame.data.members[0].comped && !hasCompedSubscription) {
|
|
await membersService.api.members.setComplimentarySubscription(member);
|
|
} else if (!(frame.data.members[0].comped) && hasCompedSubscription) {
|
|
await membersService.api.members.cancelComplimentarySubscription(member);
|
|
}
|
|
|
|
await member.load(['stripeSubscriptions']);
|
|
}
|
|
|
|
await member.load(['stripeSubscriptions.customer']);
|
|
|
|
return member;
|
|
} catch (error) {
|
|
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
|
|
throw new errors.ValidationError({
|
|
message: i18n.t('errors.models.member.memberAlreadyExists.message'),
|
|
context: i18n.t('errors.models.member.memberAlreadyExists.context', {
|
|
action: 'edit'
|
|
})
|
|
});
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
},
|
|
|
|
editSubscription: {
|
|
statusCode: 200,
|
|
headers: {},
|
|
options: [
|
|
'id',
|
|
'subscription_id'
|
|
],
|
|
data: [
|
|
'cancel_at_period_end'
|
|
],
|
|
validation: {
|
|
options: {
|
|
id: {
|
|
required: true
|
|
},
|
|
subscription_id: {
|
|
required: true
|
|
}
|
|
},
|
|
data: {
|
|
cancel_at_period_end: {
|
|
required: true
|
|
}
|
|
}
|
|
},
|
|
permissions: {
|
|
method: 'edit'
|
|
},
|
|
async query(frame) {
|
|
await membersService.api.members.updateSubscription({
|
|
id: frame.options.id,
|
|
subscription: {
|
|
subscription_id: frame.options.subscription_id,
|
|
cancel_at_period_end: frame.data.cancel_at_period_end
|
|
}
|
|
});
|
|
let model = await membersService.api.members.get({id: frame.options.id}, {
|
|
withRelated: ['labels', 'products', 'stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct']
|
|
});
|
|
if (!model) {
|
|
throw new errors.NotFoundError({
|
|
message: i18n.t('errors.api.members.memberNotFound')
|
|
});
|
|
}
|
|
|
|
return model;
|
|
}
|
|
},
|
|
|
|
createSubscription: {
|
|
statusCode: 200,
|
|
headers: {},
|
|
options: [
|
|
'id'
|
|
],
|
|
data: [
|
|
'stripe_price_id'
|
|
],
|
|
validation: {
|
|
options: {
|
|
id: {
|
|
required: true
|
|
}
|
|
},
|
|
data: {
|
|
stripe_price_id: {
|
|
required: true
|
|
}
|
|
}
|
|
},
|
|
permissions: {
|
|
method: 'edit'
|
|
},
|
|
async query(frame) {
|
|
await membersService.api.members.createSubscription({
|
|
id: frame.options.id,
|
|
subscription: {
|
|
stripe_price_id: frame.data.stripe_price_id
|
|
}
|
|
});
|
|
let model = await membersService.api.members.get({id: frame.options.id}, {
|
|
withRelated: ['labels', 'products', 'stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct']
|
|
});
|
|
if (!model) {
|
|
throw new errors.NotFoundError({
|
|
message: i18n.t('errors.api.members.memberNotFound')
|
|
});
|
|
}
|
|
|
|
return model;
|
|
}
|
|
},
|
|
|
|
destroy: {
|
|
statusCode: 204,
|
|
headers: {},
|
|
options: [
|
|
'id',
|
|
'cancel'
|
|
],
|
|
validation: {
|
|
options: {
|
|
id: {
|
|
required: true
|
|
}
|
|
}
|
|
},
|
|
permissions: true,
|
|
async query(frame) {
|
|
frame.options.require = true;
|
|
frame.options.cancelStripeSubscriptions = frame.options.cancel;
|
|
|
|
await Promise.resolve(membersService.api.members.destroy({
|
|
id: frame.options.id
|
|
}, frame.options)).catch(models.Member.NotFoundError, () => {
|
|
throw new errors.NotFoundError({
|
|
message: i18n.t('errors.api.resource.resourceNotFound', {
|
|
resource: 'Member'
|
|
})
|
|
});
|
|
});
|
|
|
|
return null;
|
|
}
|
|
},
|
|
|
|
bulkDestroy: {
|
|
statusCode: 200,
|
|
headers: {},
|
|
options: [
|
|
'all',
|
|
'filter',
|
|
'search'
|
|
],
|
|
permissions: {
|
|
method: 'destroy'
|
|
},
|
|
async query(frame) {
|
|
const {all, filter, search} = frame.options;
|
|
|
|
if (!filter && !search && (!all || all !== true)) {
|
|
throw new errors.IncorrectUsageError({
|
|
message: 'DELETE /members/ must be used with a filter or ?all=true'
|
|
});
|
|
}
|
|
|
|
const knexOptions = _.pick(frame.options, ['transacting']);
|
|
const filterOptions = Object.assign({}, knexOptions);
|
|
|
|
if (all !== true) {
|
|
if (filter) {
|
|
filterOptions.filter = filter;
|
|
}
|
|
|
|
if (search) {
|
|
filterOptions.search = search;
|
|
}
|
|
}
|
|
|
|
// fetch ids of all matching members
|
|
const memberRows = await models.Member
|
|
.getFilteredCollectionQuery(filterOptions)
|
|
.select('members.id')
|
|
.distinct();
|
|
|
|
const memberIds = memberRows.map(row => row.id);
|
|
|
|
const bulkDestroyResult = await models.Member.bulkDestroy(memberIds);
|
|
|
|
// shaped to match the importer response
|
|
return {
|
|
meta: {
|
|
stats: {
|
|
successful: bulkDestroyResult.successful,
|
|
unsuccessful: bulkDestroyResult.unsuccessful
|
|
},
|
|
unsuccessfulIds: bulkDestroyResult.unsuccessfulIds,
|
|
errors: bulkDestroyResult.errors
|
|
}
|
|
};
|
|
}
|
|
},
|
|
|
|
exportCSV: {
|
|
options: [
|
|
'limit',
|
|
'filter',
|
|
'search'
|
|
],
|
|
headers: {
|
|
disposition: {
|
|
type: 'csv',
|
|
value() {
|
|
const datetime = (new Date()).toJSON().substring(0, 10);
|
|
return `members.${datetime}.csv`;
|
|
}
|
|
}
|
|
},
|
|
response: {
|
|
format: 'plain'
|
|
},
|
|
permissions: {
|
|
method: 'browse'
|
|
},
|
|
validation: {},
|
|
async query(frame) {
|
|
frame.options.withRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer'];
|
|
const page = await membersService.api.members.list(frame.options);
|
|
|
|
return page;
|
|
}
|
|
},
|
|
|
|
importCSV: {
|
|
statusCode(result) {
|
|
if (result && result.meta && result.meta.stats && result.meta.stats.imported !== null) {
|
|
return 201;
|
|
} else {
|
|
return 202;
|
|
}
|
|
},
|
|
permissions: {
|
|
method: 'add'
|
|
},
|
|
async query(frame) {
|
|
const siteTimezone = settingsCache.get('timezone');
|
|
|
|
const importLabel = {
|
|
name: `Import ${moment().tz(siteTimezone).format('YYYY-MM-DD HH:mm')}`
|
|
};
|
|
|
|
const globalLabels = [importLabel].concat(frame.data.labels);
|
|
const pathToCSV = frame.file.path;
|
|
const headerMapping = frame.data.mapping;
|
|
|
|
return membersService.importer.process({
|
|
pathToCSV,
|
|
headerMapping,
|
|
globalLabels,
|
|
importLabel,
|
|
LabelModel: models.Label,
|
|
user: {
|
|
email: frame.user.get('email')
|
|
}
|
|
});
|
|
}
|
|
},
|
|
|
|
memberStats: {
|
|
permissions: {
|
|
method: 'browse'
|
|
},
|
|
async query() {
|
|
const memberStats = await membersService.api.events.getStatuses();
|
|
let totalMembers = _.last(memberStats) ? (_.last(memberStats).paid + _.last(memberStats).free + _.last(memberStats).comped) : 0;
|
|
|
|
return {
|
|
resource: 'members',
|
|
total: totalMembers,
|
|
data: memberStats.map((d) => {
|
|
const {paid, free, comped} = d;
|
|
return {
|
|
date: moment(d.date).format('YYYY-MM-DD'),
|
|
paid, free, comped
|
|
};
|
|
})
|
|
};
|
|
}
|
|
},
|
|
|
|
mrrStats: {
|
|
permissions: {
|
|
method: 'browse'
|
|
},
|
|
async query() {
|
|
const mrrData = await membersService.api.events.getMRR();
|
|
const mrrStats = Object.keys(mrrData).map((curr) => {
|
|
return {
|
|
currency: curr,
|
|
data: mrrData[curr].map((d) => {
|
|
return Object.assign({}, {
|
|
date: moment(d.date).format('YYYY-MM-DD'),
|
|
value: d.mrr
|
|
});
|
|
})
|
|
};
|
|
});
|
|
return {
|
|
resource: 'mrr',
|
|
data: mrrStats
|
|
};
|
|
}
|
|
},
|
|
subscriberStats: {
|
|
permissions: {
|
|
method: 'browse'
|
|
},
|
|
async query() {
|
|
const statsData = await membersService.api.events.getSubscriptions();
|
|
const totalSubscriptions = (_.last(statsData) && _.last(statsData).subscribed) || 0;
|
|
statsData.forEach((d) => {
|
|
d.date = moment(d.date).format('YYYY-MM-DD');
|
|
});
|
|
return {
|
|
resource: 'subscribers',
|
|
total: totalSubscriptions,
|
|
data: statsData.map((d) => {
|
|
return Object.assign({}, {
|
|
date: moment(d.date).format('YYYY-MM-DD'),
|
|
value: d.subscribed
|
|
});
|
|
})
|
|
};
|
|
}
|
|
},
|
|
grossVolumeStats: {
|
|
permissions: {
|
|
method: 'browse'
|
|
},
|
|
async query() {
|
|
const volumeData = await membersService.api.events.getVolume();
|
|
const volumeStats = Object.keys(volumeData).map((curr) => {
|
|
return {
|
|
currency: curr,
|
|
data: volumeData[curr].map((d) => {
|
|
return Object.assign({}, {
|
|
date: moment(d.date).format('YYYY-MM-DD'),
|
|
value: d.volume
|
|
});
|
|
})
|
|
};
|
|
});
|
|
return {
|
|
resource: 'gross-volume',
|
|
data: volumeStats
|
|
};
|
|
}
|
|
},
|
|
|
|
activityFeed: {
|
|
options: [
|
|
'limit'
|
|
],
|
|
permissions: {
|
|
method: 'browse'
|
|
},
|
|
async query(frame) {
|
|
const events = await membersService.api.events.getEventTimeline(frame.options);
|
|
return {
|
|
events
|
|
};
|
|
}
|
|
}
|
|
};
|