2019-08-09 17:11:24 +03:00
// NOTE: We must not cache references to membersService.api
// as it is a getter and may change during runtime.
2019-10-03 20:59:19 +03:00
const Promise = require ( 'bluebird' ) ;
2020-05-26 12:38:42 +03:00
const moment = require ( 'moment-timezone' ) ;
const errors = require ( '@tryghost/errors' ) ;
2020-05-27 20:47:53 +03:00
const config = require ( '../../../shared/config' ) ;
2019-12-06 08:04:10 +03:00
const models = require ( '../../models' ) ;
2019-08-09 17:11:24 +03:00
const membersService = require ( '../../services/members' ) ;
2020-05-26 12:38:42 +03:00
const settingsCache = require ( '../../services/settings/cache' ) ;
2020-05-28 21:30:23 +03:00
const { i18n } = require ( '../../lib/common' ) ;
const logging = require ( '../../../shared/logging' ) ;
2019-10-03 20:59:19 +03:00
const fsLib = require ( '../../lib/fs' ) ;
2020-05-26 12:38:42 +03:00
const db = require ( '../../data/db' ) ;
2020-02-14 12:33:10 +03:00
const _ = require ( 'lodash' ) ;
2019-08-09 17:11:24 +03:00
2020-01-28 07:25:00 +03:00
const decorateWithSubscriptions = async function ( member ) {
2020-01-15 13:52:47 +03:00
// NOTE: this logic is here until relations between Members/MemberStripeCustomer/StripeCustomerSubscription
// are in place
2020-01-28 07:25:00 +03:00
const subscriptions = await membersService . api . members . getStripeSubscriptions ( member ) ;
2020-01-15 13:52:47 +03:00
2020-01-28 07:25:00 +03:00
return Object . assign ( member , {
stripe : {
subscriptions
}
} ) ;
} ;
2020-02-12 08:33:16 +03:00
/ * * N O T E : t h i s m e t h o d s h o u l d n o t e x i s t a t a l l a n d n e e d s t o b e c l e a n e d u p
it was created due to a bug in how CSV is currently created for exports
Export bug was fixed in 3.6 but method exists to handle older csv exports with undefined
* * /
2020-02-04 08:51:24 +03:00
const cleanupUndefined = ( obj ) => {
for ( let key in obj ) {
if ( obj [ key ] === 'undefined' ) {
delete obj [ key ] ;
}
}
} ;
// NOTE: this method can be removed once unique constraints are introduced ref.: https://github.com/TryGhost/Ghost/blob/e277c6b/core/server/data/schema/schema.js#L339
const sanitizeInput = ( members ) => {
const customersMap = members . reduce ( ( acc , member ) => {
2020-02-12 08:33:16 +03:00
if ( member . stripe _customer _id && member . stripe _customer _id !== 'undefined' ) {
2020-02-04 08:51:24 +03:00
if ( acc [ member . stripe _customer _id ] ) {
acc [ member . stripe _customer _id ] += 1 ;
} else {
acc [ member . stripe _customer _id ] = 1 ;
}
}
return acc ;
} , { } ) ;
const toRemove = [ ] ;
for ( const key in customersMap ) {
if ( customersMap [ key ] > 1 ) {
toRemove . push ( key ) ;
}
}
let sanitized = members . filter ( ( member ) => {
return ! ( toRemove . includes ( member . stripe _customer _id ) ) ;
} ) ;
return sanitized ;
} ;
2020-02-14 12:33:10 +03:00
function serializeMemberLabels ( labels ) {
2020-06-05 07:08:46 +03:00
if ( _ . isString ( labels ) ) {
return [ {
name : labels . trim ( )
} ] ;
} else if ( labels ) {
2020-02-14 12:33:10 +03:00
return labels . filter ( ( label ) => {
return ! ! label ;
} ) . map ( ( label ) => {
if ( _ . isString ( label ) ) {
return {
name : label . trim ( )
} ;
}
return label ;
} ) ;
}
return [ ] ;
}
2020-01-28 07:25:00 +03:00
const listMembers = async function ( options ) {
const res = ( await models . Member . findPage ( options ) ) ;
const memberModels = res . data . map ( model => model . toJSON ( options ) ) ;
const members = await Promise . all ( memberModels . map ( async function ( member ) {
return decorateWithSubscriptions ( member ) ;
2020-01-15 13:52:47 +03:00
} ) ) ;
return {
2020-01-28 07:25:00 +03:00
members : members ,
2020-01-15 13:52:47 +03:00
meta : res . meta
} ;
} ;
2020-06-05 07:08:46 +03:00
const createLabels = async ( labels , options ) => {
const api = require ( './index' ) ;
return await Promise . all ( labels . map ( ( label ) => {
return api . labels . add . query ( {
data : {
labels : [ label ]
} ,
options : {
context : options . context
}
} ) . catch ( ( error ) => {
if ( error . errorType === 'ValidationError' ) {
return ;
}
throw error ;
} ) ;
} ) ) ;
} ;
2019-08-09 17:11:24 +03:00
const members = {
docName : 'members' ,
browse : {
options : [
'limit' ,
'fields' ,
'filter' ,
'order' ,
'debug' ,
2020-05-28 12:14:02 +03:00
'page' ,
'search'
2019-08-09 17:11:24 +03:00
] ,
permissions : true ,
validation : { } ,
2020-01-15 13:52:47 +03:00
async query ( frame ) {
return listMembers ( frame . options ) ;
2019-08-09 17:11:24 +03:00
}
} ,
read : {
headers : { } ,
data : [
'id' ,
'email'
] ,
validation : { } ,
permissions : true ,
2019-09-03 07:10:32 +03:00
async query ( frame ) {
2020-01-28 07:25:00 +03:00
let model = await models . Member . findOne ( frame . data , frame . options ) ;
2020-01-15 13:52:47 +03:00
2020-01-28 07:25:00 +03:00
if ( ! model ) {
2020-05-22 21:22:20 +03:00
throw new errors . NotFoundError ( {
message : i18n . t ( 'errors.api.members.memberNotFound' )
2019-09-03 07:10:32 +03:00
} ) ;
}
2020-01-15 13:52:47 +03:00
2020-01-28 07:25:00 +03:00
const member = model . toJSON ( frame . options ) ;
2020-01-15 13:52:47 +03:00
2020-01-28 07:25:00 +03:00
return decorateWithSubscriptions ( member ) ;
2019-08-09 17:11:24 +03:00
}
} ,
2019-10-03 12:15:50 +03: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 10:14:26 +03:00
async query ( frame ) {
2020-02-04 08:51:24 +03:00
let model ;
2019-10-09 10:14:26 +03:00
try {
2020-02-04 08:51:24 +03:00
model = await models . Member . add ( frame . data . members [ 0 ] , frame . options ) ;
const member = model . toJSON ( 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 ) ;
}
2020-01-15 13:52:47 +03:00
if ( frame . options . send _email ) {
2020-02-14 12:33:10 +03:00
await membersService . api . sendEmailWithMagicLink ( { email : model . get ( 'email' ) , requestedType : frame . options . email _type } ) ;
2020-01-15 13:52:47 +03:00
}
2020-01-28 07:25:00 +03:00
return decorateWithSubscriptions ( member ) ;
2019-10-09 10:14:26 +03:00
} catch ( error ) {
if ( error . code && error . message . toLowerCase ( ) . indexOf ( 'unique' ) !== - 1 ) {
2020-05-22 21:22:20 +03:00
throw new errors . ValidationError ( { message : i18n . t ( 'errors.api.members.memberAlreadyExists' ) } ) ;
2019-10-09 10:14:26 +03:00
}
2020-02-04 08:51:24 +03:00
// NOTE: failed to link Stripe customer/plan/subscription
if ( model && error . message && ( error . message . indexOf ( 'customer' ) || error . message . indexOf ( 'plan' ) || error . message . indexOf ( 'subscription' ) ) ) {
2020-06-05 15:06:19 +03:00
if ( error . message . indexOf ( 'customer' ) && error . code === 'resource_missing' ) {
error . context = i18n . t ( 'errors.api.members.stripeCustomerNotFound.context' ) ;
error . help = i18n . t ( 'errors.api.members.stripeCustomerNotFound.help' ) ;
}
2020-02-04 08:51:24 +03:00
const api = require ( './index' ) ;
await api . members . destroy . query ( {
options : {
context : frame . options . context ,
id : model . id
}
} ) ;
}
2019-10-09 10:14:26 +03:00
throw error ;
}
2019-10-03 12:15:50 +03:00
}
} ,
2019-10-03 14:38:22 +03:00
edit : {
statusCode : 200 ,
headers : { } ,
options : [
'id'
] ,
validation : {
options : {
id : {
required : true
}
}
} ,
permissions : true ,
async query ( frame ) {
2020-01-28 07:25:00 +03:00
const model = await models . Member . edit ( frame . data . members [ 0 ] , frame . options ) ;
const member = model . toJSON ( frame . options ) ;
const subscriptions = await membersService . api . members . getStripeSubscriptions ( member ) ;
const compedSubscriptions = subscriptions . filter ( sub => ( sub . plan . nickname === 'Complimentary' ) ) ;
if ( frame . data . members [ 0 ] . comped !== undefined && ( frame . data . members [ 0 ] . comped !== compedSubscriptions ) ) {
const hasCompedSubscription = ! ! ( compedSubscriptions . length ) ;
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 ) ;
}
}
2020-01-15 13:52:47 +03:00
2020-01-28 07:25:00 +03:00
return decorateWithSubscriptions ( member ) ;
2019-10-03 14:38:22 +03:00
}
} ,
2019-08-09 17:11:24 +03:00
destroy : {
statusCode : 204 ,
headers : { } ,
options : [
'id'
] ,
validation : {
options : {
id : {
required : true
}
}
} ,
permissions : true ,
2019-10-02 11:25:49 +03:00
async query ( frame ) {
2019-08-09 17:11:24 +03:00
frame . options . require = true ;
2020-01-15 13:52:47 +03:00
2020-02-11 11:35:18 +03:00
let member = await models . Member . findOne ( frame . options ) ;
2020-01-15 13:52:47 +03:00
if ( ! member ) {
2020-05-22 21:22:20 +03:00
throw new errors . NotFoundError ( {
message : i18n . t ( 'errors.api.resource.resourceNotFound' , {
2020-01-15 13:52:47 +03:00
resource : 'Member'
} )
} ) ;
}
// NOTE: move to a model layer once Members/MemberStripeCustomer relations are in place
await membersService . api . members . destroyStripeSubscriptions ( member ) ;
await models . Member . destroy ( frame . options )
2019-12-06 08:04:10 +03:00
. catch ( models . Member . NotFoundError , ( ) => {
2020-05-22 21:22:20 +03:00
throw new errors . NotFoundError ( {
message : i18n . t ( 'errors.api.resource.resourceNotFound' , {
2019-12-06 08:04:10 +03:00
resource : 'Member'
} )
} ) ;
} ) ;
2019-10-02 11:25:49 +03:00
return null ;
2019-08-09 17:11:24 +03:00
}
2019-10-03 20:59:19 +03:00
} ,
2019-10-03 21:36:22 +03:00
exportCSV : {
2019-10-29 07:50:32 +03:00
options : [
'limit'
] ,
2019-10-03 21:36:22 +03: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 13:52:47 +03:00
async query ( frame ) {
2020-02-14 12:33:10 +03:00
frame . options . withRelated = [ 'labels' ] ;
2020-01-15 13:52:47 +03:00
return listMembers ( frame . options ) ;
2019-10-03 21:36:22 +03:00
}
} ,
2019-10-03 20:59:19 +03:00
importCSV : {
statusCode : 201 ,
permissions : {
method : 'add'
} ,
async query ( frame ) {
2020-02-04 08:51:24 +03:00
let filePath = frame . file . path ;
let fulfilled = 0 ;
let invalid = 0 ;
let duplicates = 0 ;
const columnsToExtract = [ {
name : 'email' ,
lookup : /^email/i
} , {
name : 'name' ,
lookup : /name/i
} , {
name : 'note' ,
lookup : /note/i
} , {
name : 'subscribed_to_emails' ,
lookup : /subscribed_to_emails/i
} , {
name : 'stripe_customer_id' ,
lookup : /stripe_customer_id/i
} , {
name : 'complimentary_plan' ,
lookup : /complimentary_plan/i
2020-02-14 12:33:10 +03:00
} , {
name : 'labels' ,
lookup : /labels/i
2020-02-19 14:55:09 +03:00
} , {
name : 'created_at' ,
lookup : /created_at/i
2020-02-04 08:51:24 +03:00
} ] ;
2019-10-03 20:59:19 +03:00
2020-06-05 07:08:46 +03:00
// NOTE: custom labels have to be created in advance otherwise there are conflicts
// when processing member creation in parallel later on in import process
const importSetLabels = serializeMemberLabels ( frame . data . labels ) ;
await createLabels ( importSetLabels , frame . options ) ;
2019-10-03 20:59:19 +03:00
return fsLib . readCSV ( {
path : filePath ,
2020-02-04 08:51:24 +03:00
columnsToExtract : columnsToExtract
2019-10-03 20:59:19 +03:00
} ) . then ( ( result ) => {
2020-02-04 08:51:24 +03:00
const sanitized = sanitizeInput ( result ) ;
invalid += result . length - sanitized . length ;
2020-02-10 11:03:08 +03:00
return Promise . map ( sanitized , ( ( entry ) => {
2019-10-03 20:59:19 +03:00
const api = require ( './index' ) ;
2020-02-14 12:33:10 +03:00
entry . labels = ( entry . labels && entry . labels . split ( ',' ) ) || [ ] ;
const entryLabels = serializeMemberLabels ( entry . labels ) ;
2020-06-05 07:08:46 +03:00
const mergedLabels = _ . unionBy ( entryLabels , importSetLabels , 'name' ) ;
2020-02-04 08:51:24 +03:00
cleanupUndefined ( entry ) ;
2020-02-18 06:34:20 +03:00
let subscribed ;
if ( _ . isUndefined ( entry . subscribed _to _emails ) ) {
subscribed = entry . subscribed _to _emails ;
} else {
subscribed = ( String ( entry . subscribed _to _emails ) . toLowerCase ( ) !== 'false' ) ;
}
2019-10-09 10:14:26 +03:00
return Promise . resolve ( api . members . add . query ( {
2019-10-03 20:59:19 +03:00
data : {
members : [ {
email : entry . email ,
2019-10-10 07:51:27 +03:00
name : entry . name ,
2020-02-04 08:51:24 +03:00
note : entry . note ,
2020-02-18 06:34:20 +03:00
subscribed : subscribed ,
2020-02-04 08:51:24 +03:00
stripe _customer _id : entry . stripe _customer _id ,
2020-02-14 12:33:10 +03:00
comped : ( String ( entry . complimentary _plan ) . toLocaleLowerCase ( ) === 'true' ) ,
2020-06-05 07:08:46 +03:00
labels : mergedLabels ,
2020-03-09 15:12:02 +03:00
created _at : entry . created _at === '' ? undefined : entry . created _at
2019-10-03 20:59:19 +03:00
} ]
} ,
options : {
context : frame . options . context ,
options : { send _email : false }
}
2019-10-09 10:14:26 +03:00
} ) ) . reflect ( ) ;
2020-02-10 11:03:08 +03:00
} ) , { concurrency : 10 } )
. each ( ( inspection ) => {
if ( inspection . isFulfilled ( ) ) {
fulfilled = fulfilled + 1 ;
2019-10-03 20:59:19 +03:00
} else {
2020-05-22 21:22:20 +03:00
if ( inspection . reason ( ) instanceof errors . ValidationError ) {
2020-02-10 11:03:08 +03:00
duplicates = duplicates + 1 ;
} else {
// NOTE: if the error happens as a result of pure API call it doesn't get logged anywhere
// for this reason we have to make sure any unexpected errors are logged here
2020-02-10 11:25:56 +03:00
if ( Array . isArray ( inspection . reason ( ) ) ) {
2020-05-22 21:22:20 +03:00
logging . error ( inspection . reason ( ) [ 0 ] ) ;
2020-02-10 11:25:56 +03:00
} else {
2020-05-22 21:22:20 +03:00
logging . error ( inspection . reason ( ) ) ;
2020-02-10 11:25:56 +03:00
}
2020-02-10 11:03:08 +03:00
invalid = invalid + 1 ;
}
2019-10-03 20:59:19 +03:00
}
2020-02-10 11:03:08 +03:00
} ) ;
2019-10-03 20:59:19 +03:00
} ) . then ( ( ) => {
return {
meta : {
stats : {
imported : fulfilled ,
duplicates : duplicates ,
invalid : invalid
}
}
} ;
} ) ;
}
2020-05-26 12:38:42 +03: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' ;
const siteTimezone = settingsCache . get ( 'active_timezone' ) ;
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 ) {
const dateModifier = ` + ${ tzOffsetMins } minutes ` ;
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 {
const mins = tzOffsetMins % 60 ;
const hours = ( tzOffsetMins - mins ) / 60 ;
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 17:11:24 +03:00
}
} ;
module . exports = members ;