mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 11:55:03 +03:00
a68b96001c
no-issue This removes logic from the Members API controller, and into the Members BREAD service, this allows our controllers to be simpler and easier to maintain, as well as keeping the important logic all together.
513 lines
14 KiB
JavaScript
513 lines
14 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 labsService = require('../../../shared/labs');
|
||
|
||
const settingsCache = require('../../../shared/settings-cache');
|
||
const tpl = require('@tryghost/tpl');
|
||
const _ = require('lodash');
|
||
|
||
const messages = {
|
||
memberNotFound: 'Member not found.',
|
||
memberAlreadyExists: {
|
||
message: 'Member already exists',
|
||
context: 'Attempting to {action} member with existing email address.'
|
||
},
|
||
stripeNotConnected: {
|
||
message: 'Missing Stripe connection.',
|
||
context: 'Attempting to import members with Stripe data when there is no Stripe account connected.',
|
||
help: 'help'
|
||
},
|
||
stripeCustomerNotFound: {
|
||
context: 'Missing Stripe customer.',
|
||
help: 'Make sure you’re connected to the correct Stripe Account.'
|
||
},
|
||
resourceNotFound: '{resource} not found.'
|
||
};
|
||
|
||
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) {
|
||
const page = await membersService.api.memberBREADService.browse(frame.options);
|
||
|
||
return page;
|
||
}
|
||
},
|
||
|
||
read: {
|
||
options: [
|
||
'include'
|
||
],
|
||
headers: {},
|
||
data: [
|
||
'id',
|
||
'email'
|
||
],
|
||
validation: {
|
||
options: {
|
||
include: {
|
||
values: allowedIncludes
|
||
}
|
||
}
|
||
},
|
||
permissions: true,
|
||
async query(frame) {
|
||
const member = await membersService.api.memberBREADService.read(frame.data, frame.options);
|
||
|
||
if (!member) {
|
||
throw new errors.NotFoundError({
|
||
message: tpl(messages.memberNotFound)
|
||
});
|
||
}
|
||
|
||
return member;
|
||
}
|
||
},
|
||
|
||
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) {
|
||
const member = await membersService.api.memberBREADService.add(frame.data.members[0], frame.options);
|
||
|
||
return member;
|
||
}
|
||
},
|
||
|
||
edit: {
|
||
statusCode: 200,
|
||
headers: {},
|
||
options: [
|
||
'id'
|
||
],
|
||
validation: {
|
||
options: {
|
||
id: {
|
||
required: true
|
||
}
|
||
}
|
||
},
|
||
permissions: true,
|
||
async query(frame) {
|
||
const member = await membersService.api.memberBREADService.edit(frame.data.members[0], frame.options);
|
||
|
||
return member;
|
||
}
|
||
},
|
||
|
||
editSubscription: {
|
||
statusCode: 200,
|
||
headers: {},
|
||
options: [
|
||
'id',
|
||
'subscription_id'
|
||
],
|
||
data: [
|
||
'cancel_at_period_end',
|
||
'status'
|
||
],
|
||
validation: {
|
||
options: {
|
||
id: {
|
||
required: true
|
||
},
|
||
subscription_id: {
|
||
required: true
|
||
}
|
||
},
|
||
data: {
|
||
cancel_at_period_end: {
|
||
required: true
|
||
},
|
||
status: {
|
||
values: ['canceled']
|
||
}
|
||
}
|
||
},
|
||
permissions: {
|
||
method: 'edit'
|
||
},
|
||
async query(frame) {
|
||
if (frame.data.status === 'canceled') {
|
||
await membersService.api.members.cancelSubscription({
|
||
id: frame.options.id,
|
||
subscription: {
|
||
subscription_id: frame.options.subscription_id
|
||
}
|
||
});
|
||
} else {
|
||
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: tpl(messages.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: tpl(messages.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: tpl(messages.resourceNotFound, {
|
||
resource: 'Member'
|
||
})
|
||
});
|
||
});
|
||
|
||
return null;
|
||
}
|
||
},
|
||
|
||
bulkDestroy: {
|
||
statusCode: 200,
|
||
headers: {},
|
||
options: [
|
||
'all',
|
||
'filter',
|
||
'search'
|
||
],
|
||
permissions: {
|
||
method: 'destroy'
|
||
},
|
||
async query(frame) {
|
||
const bulkDestroyResult = await membersService.api.members.bulkDestroy(frame.options);
|
||
|
||
// shaped to match the importer response
|
||
return {
|
||
meta: {
|
||
stats: {
|
||
successful: bulkDestroyResult.successful,
|
||
unsuccessful: bulkDestroyResult.unsuccessful
|
||
},
|
||
unsuccessfulIds: bulkDestroyResult.unsuccessfulIds,
|
||
errors: bulkDestroyResult.errors
|
||
}
|
||
};
|
||
}
|
||
},
|
||
|
||
bulkEdit: {
|
||
statusCode: 200,
|
||
headers: {},
|
||
options: [
|
||
'all',
|
||
'filter',
|
||
'search'
|
||
],
|
||
data: [
|
||
'action',
|
||
'meta'
|
||
],
|
||
validation: {
|
||
data: {
|
||
action: {
|
||
required: true,
|
||
values: ['unsubscribe', 'addLabel', 'removeLabel']
|
||
}
|
||
}
|
||
},
|
||
permissions: {
|
||
method: 'edit'
|
||
},
|
||
async query(frame) {
|
||
return membersService.api.members.bulkEdit(frame.data.bulk, frame.options);
|
||
}
|
||
},
|
||
|
||
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'];
|
||
if (labsService.isSet('multipleProducts')) {
|
||
frame.options.withRelated.push('products');
|
||
}
|
||
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.processImport({
|
||
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
|
||
};
|
||
}
|
||
}
|
||
};
|