2019-08-09 19:41:24 +05:30
// NOTE: We must not cache references to membersService.api
// as it is a getter and may change during runtime.
2019-10-03 19:59:19 +02:00
const Promise = require ( 'bluebird' ) ;
2020-05-26 10:38:42 +01:00
const moment = require ( 'moment-timezone' ) ;
const errors = require ( '@tryghost/errors' ) ;
2020-12-09 16:15:53 +00:00
const GhostMailer = require ( '../../services/mail' ) . GhostMailer ;
2020-05-27 12:47:53 -05:00
const config = require ( '../../../shared/config' ) ;
2019-12-06 12:04:10 +07:00
const models = require ( '../../models' ) ;
2019-08-09 19:41:24 +05:30
const membersService = require ( '../../services/members' ) ;
2020-12-09 16:15:53 +00:00
const jobsService = require ( '../../services/jobs' ) ;
2020-05-26 10:38:42 +01:00
const settingsCache = require ( '../../services/settings/cache' ) ;
2020-05-28 13:30:23 -05:00
const { i18n } = require ( '../../lib/common' ) ;
2020-05-26 10:38:42 +01:00
const db = require ( '../../data/db' ) ;
2019-08-09 19:41:24 +05:30
2020-12-09 16:15:53 +00:00
const ghostMailer = new GhostMailer ( ) ;
2020-12-10 10:04:05 +00:00
const allowedIncludes = [ 'email_recipients' ] ;
2020-02-04 13:51:24 +08:00
2020-08-12 14:17:44 +01:00
module . exports = {
2019-08-09 19:41:24 +05:30
docName : 'members' ,
2020-06-18 18:07:02 +02:00
hasActiveStripeSubscriptions : {
permissions : {
method : 'browse'
} ,
async query ( ) {
const hasActiveStripeSubscriptions = await membersService . api . hasActiveStripeSubscriptions ( ) ;
return {
hasActiveStripeSubscriptions
} ;
}
} ,
2019-08-09 19:41:24 +05:30
browse : {
options : [
'limit' ,
'fields' ,
'filter' ,
'order' ,
'debug' ,
2020-05-28 10:14:02 +01:00
'page' ,
2020-06-12 12:12:10 +01:00
'search' ,
'paid'
2019-08-09 19:41:24 +05:30
] ,
permissions : true ,
validation : { } ,
2020-01-15 17:52:47 +07:00
async query ( frame ) {
2020-08-12 14:17:44 +01:00
frame . options . withRelated = [ 'labels' , 'stripeSubscriptions' , 'stripeSubscriptions.customer' ] ;
const page = await membersService . api . members . list ( frame . options ) ;
2020-09-30 10:22:22 +01:00
return page ;
2019-08-09 19:41:24 +05:30
}
} ,
read : {
2020-12-10 10:04:05 +00:00
options : [
'include'
] ,
2019-08-09 19:41:24 +05:30
headers : { } ,
data : [
'id' ,
'email'
] ,
2020-12-10 10:04:05 +00:00
validation : {
options : {
include : {
values : allowedIncludes
}
}
} ,
2019-08-09 19:41:24 +05:30
permissions : true ,
2019-09-03 12:10:32 +08:00
async query ( frame ) {
2020-12-10 10:04:05 +00:00
const defaultWithRelated = [ 'labels' , 'stripeSubscriptions' , 'stripeSubscriptions.customer' ] ;
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' ) ;
}
2020-08-12 14:17:44 +01:00
let model = await membersService . api . members . get ( frame . data , frame . options ) ;
2020-01-15 17:52:47 +07:00
2020-01-28 11:25:00 +07:00
if ( ! model ) {
2020-05-22 13:22:20 -05:00
throw new errors . NotFoundError ( {
message : i18n . t ( 'errors.api.members.memberNotFound' )
2019-09-03 12:10:32 +08:00
} ) ;
}
2020-01-15 17:52:47 +07:00
2020-09-30 10:22:22 +01:00
return model ;
2019-08-09 19:41:24 +05:30
}
} ,
2019-10-03 11:15:50 +02:00
add : {
statusCode : 201 ,
headers : { } ,
options : [
'send_email' ,
'email_type'
] ,
validation : {
data : {
email : { required : true }
} ,
options : {
email _type : {
values : [ 'signin' , 'signup' , 'subscribe' ]
}
}
} ,
permissions : true ,
2019-10-09 14:14:26 +07:00
async query ( frame ) {
2020-08-12 14:17:44 +01:00
let member ;
frame . options . withRelated = [ 'stripeSubscriptions' , 'stripeSubscriptions.customer' ] ;
2019-10-09 14:14:26 +07:00
try {
2020-10-14 13:24:09 +13:00
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
} ) ;
}
2020-08-12 14:17:44 +01:00
member = await membersService . api . members . create ( frame . data . members [ 0 ] , frame . options ) ;
2020-02-04 13:51:24 +08:00
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 ) ;
}
2020-01-15 17:52:47 +07:00
if ( frame . options . send _email ) {
2020-08-12 14:17:44 +01:00
await membersService . api . sendEmailWithMagicLink ( { email : member . get ( 'email' ) , requestedType : frame . options . email _type } ) ;
2020-01-15 17:52:47 +07:00
}
2020-09-30 10:22:22 +01:00
return member ;
2019-10-09 14:14:26 +07:00
} catch ( error ) {
if ( error . code && error . message . toLowerCase ( ) . indexOf ( 'unique' ) !== - 1 ) {
2020-06-30 00:22:52 +12:00
throw new errors . ValidationError ( {
2020-08-20 17:41:47 +12:00
message : i18n . t ( 'errors.models.member.memberAlreadyExists.message' ) ,
2020-09-10 09:33:57 +05:30
context : i18n . t ( 'errors.models.member.memberAlreadyExists.context' , {
action : 'add'
} )
2020-06-30 00:22:52 +12:00
} ) ;
2019-10-09 14:14:26 +07:00
}
2020-07-01 19:03:12 +12:00
// 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
2020-10-14 13:24:09 +13:00
const isStripeLinkingError = error . message && ( error . message . match ( /customer|plan|subscription/g ) ) ;
2020-08-12 14:17:44 +01:00
if ( member && isStripeLinkingError ) {
2020-06-06 00:06:19 +12:00
if ( error . message . indexOf ( 'customer' ) && error . code === 'resource_missing' ) {
2020-06-12 16:33:45 +12:00
error . message = ` Member not imported. ${ error . message } ` ;
2020-06-06 00:06:19 +12:00
error . context = i18n . t ( 'errors.api.members.stripeCustomerNotFound.context' ) ;
error . help = i18n . t ( 'errors.api.members.stripeCustomerNotFound.help' ) ;
}
2020-08-12 14:17:44 +01:00
await membersService . api . members . destroy ( {
id : member . get ( 'id' )
} , frame . options ) ;
2020-02-04 13:51:24 +08:00
}
2019-10-09 14:14:26 +07:00
throw error ;
}
2019-10-03 11:15:50 +02:00
}
} ,
2019-10-03 13:38:22 +02:00
edit : {
statusCode : 200 ,
headers : { } ,
options : [
'id'
] ,
validation : {
options : {
id : {
required : true
}
}
} ,
permissions : true ,
async query ( frame ) {
2020-09-10 09:33:57 +05:30
try {
frame . options . withRelated = [ 'stripeSubscriptions' ] ;
const member = await membersService . api . members . update ( frame . data . members [ 0 ] , frame . options ) ;
2020-01-28 11:25:00 +07:00
2021-01-04 17:12:57 +00:00
const hasCompedSubscription = ! ! member . related ( 'stripeSubscriptions' ) . find ( sub => sub . get ( 'plan_nickname' ) === 'Complimentary' && sub . get ( 'status' ) === 'active' ) ;
2020-01-28 11:25:00 +07:00
2020-09-10 09:33:57 +05:30
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' ] ) ;
2020-01-28 11:25:00 +07:00
}
2020-08-12 14:17:44 +01:00
2020-09-10 09:33:57 +05:30
await member . load ( [ 'stripeSubscriptions.customer' ] ) ;
2020-01-15 17:52:47 +07:00
2020-09-30 10:22:22 +01:00
return member ;
2020-09-10 09:33:57 +05:30
} 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'
} )
} ) ;
}
2020-08-12 14:17:44 +01:00
2020-09-10 09:33:57 +05:30
throw error ;
}
2019-10-03 13:38:22 +02:00
}
} ,
2020-08-20 17:28:11 +05:30
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 ( frame . options . id , {
subscriptionId : frame . options . subscription _id ,
cancelAtPeriodEnd : frame . data . cancel _at _period _end
} ) ;
let model = await membersService . api . members . get ( { id : frame . options . id } , {
withRelated : [ 'labels' , 'stripeSubscriptions' , 'stripeSubscriptions.customer' ]
} ) ;
if ( ! model ) {
throw new errors . NotFoundError ( {
message : i18n . t ( 'errors.api.members.memberNotFound' )
} ) ;
}
2020-09-30 10:22:22 +01:00
return model ;
2020-08-20 17:28:11 +05:30
}
} ,
2019-08-09 19:41:24 +05:30
destroy : {
statusCode : 204 ,
headers : { } ,
options : [
2020-07-24 12:39:08 +02:00
'id' ,
'cancel'
2019-08-09 19:41:24 +05:30
] ,
validation : {
options : {
id : {
required : true
}
}
} ,
permissions : true ,
2019-10-02 15:25:49 +07:00
async query ( frame ) {
2019-08-09 19:41:24 +05:30
frame . options . require = true ;
2020-08-12 14:17:44 +01:00
frame . options . cancelStripeSubscriptions = frame . options . cancel ;
2020-01-15 17:52:47 +07:00
2020-08-21 15:14:37 +05:30
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'
} )
2019-12-06 12:04:10 +07:00
} ) ;
2020-08-21 15:14:37 +05:30
} ) ;
2019-12-06 12:04:10 +07:00
2019-10-02 15:25:49 +07:00
return null ;
2019-08-09 19:41:24 +05:30
}
2019-10-03 19:59:19 +02:00
} ,
2019-10-03 20:36:22 +02:00
exportCSV : {
2019-10-29 10:20:32 +05:30
options : [
2020-09-23 11:46:08 +01:00
'limit' ,
'filter' ,
'search' ,
'paid'
2019-10-29 10:20:32 +05:30
] ,
2019-10-03 20:36:22 +02:00
headers : {
disposition : {
type : 'csv' ,
value ( ) {
const datetime = ( new Date ( ) ) . toJSON ( ) . substring ( 0 , 10 ) ;
return ` members. ${ datetime } .csv ` ;
}
}
} ,
response : {
format : 'plain'
} ,
permissions : {
method : 'browse'
} ,
validation : { } ,
2020-01-15 17:52:47 +07:00
async query ( frame ) {
2020-08-12 14:17:44 +01:00
frame . options . withRelated = [ 'labels' , 'stripeSubscriptions' , 'stripeSubscriptions.customer' ] ;
const page = await membersService . api . members . list ( frame . options ) ;
2020-09-30 10:22:22 +01:00
return page ;
2019-10-03 20:36:22 +02:00
}
} ,
2019-10-03 19:59:19 +02:00
importCSV : {
2020-12-09 16:15:53 +00:00
statusCode ( result ) {
if ( result && result . meta && result . meta . stats && result . meta . stats . imported !== null ) {
return 201 ;
} else {
return 202 ;
}
} ,
2019-10-03 19:59:19 +02:00
permissions : {
method : 'add'
} ,
async query ( frame ) {
2020-12-09 16:15:53 +00:00
const siteTimezone = settingsCache . get ( 'timezone' ) ;
2020-08-06 13:58:32 +12:00
2020-12-09 16:15:53 +00:00
const importLabel = {
name : ` Import ${ moment ( ) . tz ( siteTimezone ) . format ( 'YYYY-MM-DD HH:mm' ) } `
} ;
2020-08-06 13:58:32 +12:00
2020-12-09 16:15:53 +00:00
const globalLabels = [ importLabel ] . concat ( frame . data . labels ) ;
const pathToCSV = frame . file . path ;
const headerMapping = frame . data . mapping ;
const job = await membersService . importer . prepare ( pathToCSV , headerMapping , globalLabels , {
createdBy : frame . user . id
} ) ;
2020-08-25 19:23:05 +12:00
2020-12-09 16:15:53 +00:00
if ( job . batches <= 500 && ! job . metadata . hasStripeData ) {
const result = await membersService . importer . perform ( job . id ) ;
const importLabelModel = result . imported ? await models . Label . findOne ( importLabel ) : null ;
2020-08-06 13:58:32 +12:00
return {
meta : {
stats : {
2020-12-09 16:15:53 +00:00
imported : result . imported ,
invalid : result . errors
2020-08-06 13:58:32 +12:00
} ,
2020-12-09 16:15:53 +00:00
import _label : importLabelModel
2020-08-06 13:58:32 +12:00
}
} ;
2020-12-09 16:15:53 +00:00
} else {
const emailRecipient = frame . user . get ( 'email' ) ;
jobsService . addJob ( async ( ) => {
const result = await membersService . importer . perform ( job . id ) ;
const importLabelModel = result . imported ? await models . Label . findOne ( importLabel ) : null ;
const emailContent = membersService . importer . generateCompletionEmail ( result , {
emailRecipient ,
importLabel : importLabelModel ? importLabelModel . toJSON ( ) : null
} ) ;
const errorCSV = membersService . importer . generateErrorCSV ( result ) ;
2020-12-10 12:19:26 +01:00
const emailSubject = result . imported > 0 ? 'Your member import is complete' : 'Your member import was unsuccessful' ;
2020-12-09 16:15:53 +00:00
await ghostMailer . send ( {
to : emailRecipient ,
2020-12-10 12:19:26 +01:00
subject : emailSubject ,
2020-12-09 16:15:53 +00:00
html : emailContent ,
forceTextContent : true ,
attachments : [ {
filename : ` ${ importLabel . name } - Errors.csv ` ,
contents : errorCSV ,
contentType : 'text/csv' ,
contentDisposition : 'attachment'
} ]
} ) ;
} ) ;
return { } ;
}
2020-08-06 13:58:32 +12:00
}
} ,
2020-05-26 10:38:42 +01:00
stats : {
options : [
'days'
] ,
permissions : {
method : 'browse'
} ,
validation : {
options : {
days : {
values : [ '30' , '90' , '365' , 'all-time' ]
}
}
} ,
async query ( frame ) {
const dateFormat = 'YYYY-MM-DD HH:mm:ss' ;
const isSQLite = config . get ( 'database:client' ) === 'sqlite3' ;
2020-06-22 23:21:00 +12:00
const siteTimezone = settingsCache . get ( 'timezone' ) ;
2020-05-26 10:38:42 +01:00
const tzOffsetMins = moment . tz ( siteTimezone ) . utcOffset ( ) ;
const days = frame . options . days === 'all-time' ? 'all-time' : Number ( frame . options . days || 30 ) ;
// get total members before other stats because the figure is used multiple times
async function getTotalMembers ( ) {
const result = await db . knex . raw ( 'SELECT COUNT(id) AS total FROM members' ) ;
return isSQLite ? result [ 0 ] . total : result [ 0 ] [ 0 ] . total ;
}
const totalMembers = await getTotalMembers ( ) ;
async function getTotalMembersInRange ( ) {
if ( days === 'all-time' ) {
return totalMembers ;
}
const startOfRange = moment . tz ( siteTimezone ) . subtract ( days - 1 , 'days' ) . startOf ( 'day' ) . utc ( ) . format ( dateFormat ) ;
const result = await db . knex . raw ( 'SELECT COUNT(id) AS total FROM members WHERE created_at >= ?' , [ startOfRange ] ) ;
return isSQLite ? result [ 0 ] . total : result [ 0 ] [ 0 ] . total ;
}
async function getTotalMembersOnDatesInRange ( ) {
const startOfRange = moment . tz ( siteTimezone ) . subtract ( days - 1 , 'days' ) . startOf ( 'day' ) . utc ( ) . format ( dateFormat ) ;
let result ;
if ( isSQLite ) {
2020-08-28 18:39:13 +09:30
const dateModifier = ` ${ Math . sign ( tzOffsetMins ) === - 1 ? '' : '+' } ${ tzOffsetMins } minutes ` ;
2020-05-26 10:38:42 +01:00
result = await db . knex ( 'members' )
. select ( db . knex . raw ( 'DATE(created_at, ?) AS created_at, COUNT(DATE(created_at, ?)) AS count' , [ dateModifier , dateModifier ] ) )
. where ( ( builder ) => {
if ( days !== 'all-time' ) {
builder . whereRaw ( 'created_at >= ?' , [ startOfRange ] ) ;
}
} ) . groupByRaw ( 'DATE(created_at, ?)' , [ dateModifier ] ) ;
} else {
2020-08-28 18:39:13 +09:30
const mins = Math . abs ( tzOffsetMins ) % 60 ;
const hours = ( Math . abs ( tzOffsetMins ) - mins ) / 60 ;
2020-05-26 10:38:42 +01:00
const utcOffset = ` ${ Math . sign ( tzOffsetMins ) === - 1 ? '-' : '+' } ${ hours } : ${ mins < 10 ? '0' : '' } ${ mins } ` ;
result = await db . knex ( 'members' )
. select ( db . knex . raw ( 'DATE(CONVERT_TZ(created_at, \'+00:00\', ?)) AS created_at, COUNT(CONVERT_TZ(created_at, \'+00:00\', ?)) AS count' , [ utcOffset , utcOffset ] ) )
. where ( ( builder ) => {
if ( days !== 'all-time' ) {
builder . whereRaw ( 'created_at >= ?' , [ startOfRange ] ) ;
}
} )
. groupByRaw ( 'DATE(CONVERT_TZ(created_at, \'+00:00\', ?))' , [ utcOffset ] ) ;
}
// sql doesn't return rows with a 0 count so we build an object
// with sparse results to reference by date rather than performing
// multiple finds across an array
const resultObject = { } ;
result . forEach ( ( row ) => {
resultObject [ moment ( row . created _at ) . format ( 'YYYY-MM-DD' ) ] = row . count ;
} ) ;
// loop over every date in the range so we can return a contiguous range object
const totalInRange = Object . values ( resultObject ) . reduce ( ( acc , value ) => acc + value , 0 ) ;
let runningTotal = totalMembers - totalInRange ;
let currentRangeDate ;
if ( days === 'all-time' ) {
// start from the date of first created member
currentRangeDate = moment ( moment ( result [ 0 ] . created _at ) . format ( 'YYYY-MM-DD' ) ) . tz ( siteTimezone ) ;
} else {
currentRangeDate = moment . tz ( siteTimezone ) . subtract ( days - 1 , 'days' ) ;
}
let endDate = moment . tz ( siteTimezone ) . add ( 1 , 'hour' ) ;
const output = { } ;
while ( currentRangeDate . isBefore ( endDate ) ) {
let dateStr = currentRangeDate . format ( 'YYYY-MM-DD' ) ;
runningTotal += resultObject [ dateStr ] || 0 ;
output [ dateStr ] = runningTotal ;
currentRangeDate = currentRangeDate . add ( 1 , 'day' ) ;
}
return output ;
}
async function getNewMembersToday ( ) {
const startOfToday = moment . tz ( siteTimezone ) . startOf ( 'day' ) . utc ( ) . format ( dateFormat ) ;
const result = await db . knex . raw ( 'SELECT count(id) AS total FROM members WHERE created_at >= ?' , [ startOfToday ] ) ;
return isSQLite ? result [ 0 ] . total : result [ 0 ] [ 0 ] . total ;
}
// perform final calculations in parallel
const results = await Promise . props ( {
total : totalMembers ,
total _in _range : getTotalMembersInRange ( ) ,
total _on _date : getTotalMembersOnDatesInRange ( ) ,
new _today : getNewMembersToday ( )
} ) ;
return results ;
}
2019-08-09 19:41:24 +05:30
}
} ;