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-08-12 11:18:30 +03:00
const doImport = require ( '../../services/members/importer' ) ;
2020-08-27 12:47:00 +03:00
const memberLabelsImporter = require ( '../../services/members/importer/labels' ) ;
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' ) ;
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-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 ] ;
}
}
} ;
2020-08-17 14:28:57 +03:00
const sanitizeInput = async ( members ) => {
const validationErrors = [ ] ;
let invalidCount = 0 ;
const jsonSchema = require ( './utils/validators/utils/json-schema' ) ;
let invalidValidationCount = 0 ;
try {
2020-09-30 06:25:12 +03:00
await jsonSchema . validate ( {
docName : 'members' ,
method : 'upload'
} , {
data : members
} ) ;
2020-08-17 14:28:57 +03:00
} catch ( error ) {
if ( error . errorDetails && error . errorDetails . length ) {
const jsonPointerIndexRegex = /\[(?<index>\d+)\]/ ;
let invalidRecordIndexes = error . errorDetails . map ( ( errorDetail ) => {
if ( errorDetail . dataPath ) {
const key = errorDetail . dataPath . split ( '.' ) . pop ( ) ;
const [ , index ] = errorDetail . dataPath . match ( jsonPointerIndexRegex ) ;
validationErrors . push ( new errors . ValidationError ( {
message : i18n . t ( 'notices.data.validation.index.schemaValidationFailed' , {
key
} ) ,
context : ` ${ key } ${ errorDetail . message } ` ,
errorDetails : ` ${ errorDetail . dataPath } with value ${ members [ index ] [ key ] } `
} ) ) ;
return Number ( index ) ;
}
} ) ;
invalidRecordIndexes = _ . uniq ( invalidRecordIndexes ) ;
invalidRecordIndexes = invalidRecordIndexes . filter ( index => ( index !== undefined ) ) ;
invalidRecordIndexes . forEach ( ( index ) => {
members [ index ] = undefined ;
} ) ;
members = members . filter ( record => ( record !== undefined ) ) ;
invalidValidationCount += invalidRecordIndexes . length ;
}
}
invalidCount += invalidValidationCount ;
2020-08-20 08:08:19 +03:00
const stripeIsConnected = membersService . config . isStripeConnected ( ) ;
const hasStripeConnectedMembers = members . find ( member => ( member . stripe _customer _id || member . comped ) ) ;
if ( ! stripeIsConnected && hasStripeConnectedMembers ) {
let nonFilteredMembersCount = members . length ;
members = members . filter ( member => ! ( member . stripe _customer _id || member . comped ) ) ;
const stripeConnectedMembers = ( nonFilteredMembersCount - members . length ) ;
if ( stripeConnectedMembers ) {
invalidCount += stripeConnectedMembers ;
validationErrors . push ( 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' )
} ) ) ;
}
}
2020-02-04 08:51:24 +03:00
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 ) ) ;
} ) ;
2020-08-17 14:28:57 +03:00
const duplicateStripeCustomersCount = ( members . length - sanitized . length ) ;
if ( duplicateStripeCustomersCount ) {
validationErrors . push ( new errors . ValidationError ( {
message : i18n . t ( 'errors.api.members.duplicateStripeCustomerIds.message' ) ,
context : i18n . t ( 'errors.api.members.duplicateStripeCustomerIds.context' ) ,
help : i18n . t ( 'errors.api.members.duplicateStripeCustomerIds.help' )
} ) ) ;
}
invalidCount += duplicateStripeCustomersCount ;
2020-08-13 13:12:20 +03:00
return {
sanitized ,
2020-08-17 14:28:57 +03:00
invalidCount ,
2020-08-27 12:47:00 +03:00
validationErrors ,
duplicateStripeCustomersCount
2020-08-13 13:12:20 +03:00
} ;
2020-08-13 13:14:52 +03:00
} ;
2020-02-04 08:51:24 +03:00
2020-08-12 16:17:44 +03:00
module . exports = {
2019-08-09 17:11:24 +03:00
docName : 'members' ,
2020-06-18 19:07:02 +03:00
hasActiveStripeSubscriptions : {
permissions : {
method : 'browse'
} ,
async query ( ) {
const hasActiveStripeSubscriptions = await membersService . api . hasActiveStripeSubscriptions ( ) ;
return {
hasActiveStripeSubscriptions
} ;
}
} ,
2019-08-09 17:11:24 +03:00
browse : {
options : [
'limit' ,
'fields' ,
'filter' ,
'order' ,
'debug' ,
2020-05-28 12:14:02 +03:00
'page' ,
2020-06-12 14:12:10 +03:00
'search' ,
'paid'
2019-08-09 17:11:24 +03:00
] ,
permissions : true ,
validation : { } ,
2020-01-15 13:52:47 +03:00
async query ( frame ) {
2020-08-12 16:17:44 +03:00
frame . options . withRelated = [ 'labels' , 'stripeSubscriptions' , 'stripeSubscriptions.customer' ] ;
const page = await membersService . api . members . list ( frame . options ) ;
const members = page . data . map ( model => model . toJSON ( frame . options ) ) ;
return {
members : members ,
meta : page . meta
} ;
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-08-12 16:17:44 +03:00
frame . options . withRelated = [ 'labels' , 'stripeSubscriptions' , 'stripeSubscriptions.customer' ] ;
let model = await membersService . api . members . get ( 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-08-12 16:17:44 +03:00
return model . toJSON ( frame . options ) ;
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-08-12 16:17:44 +03:00
let member ;
frame . options . withRelated = [ 'stripeSubscriptions' , 'stripeSubscriptions.customer' ] ;
2019-10-09 10:14:26 +03:00
try {
2020-08-12 16:17:44 +03:00
member = await membersService . api . members . create ( frame . data . members [ 0 ] , frame . options ) ;
2020-02-04 08:51:24 +03:00
if ( frame . data . members [ 0 ] . stripe _customer _id ) {
2020-06-09 14:02:38 +03:00
if ( ! membersService . config . isStripeConnected ( ) ) {
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' )
} ) ;
}
2020-02-04 08:51:24 +03:00
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-08-12 16:17:44 +03:00
await membersService . api . sendEmailWithMagicLink ( { email : member . get ( 'email' ) , requestedType : frame . options . email _type } ) ;
2020-01-15 13:52:47 +03:00
}
2020-08-12 16:17:44 +03:00
return member . toJSON ( frame . options ) ;
2019-10-09 10:14:26 +03:00
} catch ( error ) {
if ( error . code && error . message . toLowerCase ( ) . indexOf ( 'unique' ) !== - 1 ) {
2020-06-29 15:22:52 +03:00
throw new errors . ValidationError ( {
2020-08-20 08:41:47 +03:00
message : i18n . t ( 'errors.models.member.memberAlreadyExists.message' ) ,
2020-09-10 07:03:57 +03:00
context : i18n . t ( 'errors.models.member.memberAlreadyExists.context' , {
action : 'add'
} )
2020-06-29 15:22:52 +03:00
} ) ;
2019-10-09 10:14:26 +03:00
}
2020-07-01 10:03:12 +03: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
const isStripeLinkingError = error . message && ( error . message . match ( /customer|plan|subscription/g ) || error . context === i18n . t ( 'errors.api.members.stripeNotConnected.context' ) ) ;
2020-08-12 16:17:44 +03:00
if ( member && isStripeLinkingError ) {
2020-06-05 15:06:19 +03:00
if ( error . message . indexOf ( 'customer' ) && error . code === 'resource_missing' ) {
2020-06-12 07:33:45 +03:00
error . message = ` Member not imported. ${ error . message } ` ;
2020-06-05 15:06:19 +03:00
error . context = i18n . t ( 'errors.api.members.stripeCustomerNotFound.context' ) ;
error . help = i18n . t ( 'errors.api.members.stripeCustomerNotFound.help' ) ;
}
2020-08-12 16:17:44 +03:00
await membersService . api . members . destroy ( {
id : member . get ( 'id' )
} , frame . options ) ;
2020-02-04 08:51:24 +03:00
}
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-09-10 07:03:57 +03:00
try {
frame . options . withRelated = [ 'stripeSubscriptions' ] ;
const member = await membersService . api . members . update ( frame . data . members [ 0 ] , frame . options ) ;
2020-01-28 07:25:00 +03:00
2020-09-10 07:03:57 +03:00
const hasCompedSubscription = ! ! member . related ( 'stripeSubscriptions' ) . find ( subscription => subscription . get ( 'plan_nickname' ) === 'Complimentary' ) ;
2020-01-28 07:25:00 +03:00
2020-09-10 07:03:57 +03:00
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 07:25:00 +03:00
}
2020-08-12 16:17:44 +03:00
2020-09-10 07:03:57 +03:00
await member . load ( [ 'stripeSubscriptions.customer' ] ) ;
2020-01-15 13:52:47 +03:00
2020-09-10 07:03:57 +03:00
return member . toJSON ( frame . options ) ;
} 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 16:17:44 +03:00
2020-09-10 07:03:57 +03:00
throw error ;
}
2019-10-03 14:38:22 +03:00
}
} ,
2020-08-20 14:58:11 +03:00
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' )
} ) ;
}
return model . toJSON ( frame . options ) ;
}
} ,
2019-08-09 17:11:24 +03:00
destroy : {
statusCode : 204 ,
headers : { } ,
options : [
2020-07-24 13:39:08 +03:00
'id' ,
'cancel'
2019-08-09 17:11:24 +03:00
] ,
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-08-12 16:17:44 +03:00
frame . options . cancelStripeSubscriptions = frame . options . cancel ;
2020-01-15 13:52:47 +03:00
2020-08-21 12:44:37 +03:00
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 08:04:10 +03:00
} ) ;
2020-08-21 12:44:37 +03:00
} ) ;
2019-12-06 08:04:10 +03:00
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 : [
2020-09-23 13:46:08 +03:00
'limit' ,
'filter' ,
'search' ,
'paid'
2019-10-29 07:50:32 +03:00
] ,
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-08-12 16:17:44 +03:00
frame . options . withRelated = [ 'labels' , 'stripeSubscriptions' , 'stripeSubscriptions.customer' ] ;
const page = await membersService . api . members . list ( frame . options ) ;
const members = page . data . map ( model => model . toJSON ( frame . options ) ) ;
return {
members : members ,
meta : page . meta
} ;
2019-10-03 21:36:22 +03:00
}
} ,
2020-06-12 07:33:45 +03:00
validateImport : {
permissions : {
method : 'add'
} ,
headers : { } ,
async query ( frame ) {
const importedMembers = frame . data . members ;
await Promise . map ( importedMembers , ( async ( entry ) => {
if ( entry . stripe _customer _id ) {
if ( ! membersService . config . isStripeConnected ( ) ) {
throw new errors . ValidationError ( {
message : i18n . t ( 'errors.api.members.stripeNotConnected.message' , {
id : entry . stripe _customer _id
} ) ,
context : i18n . t ( 'errors.api.members.stripeNotConnected.context' ) ,
help : i18n . t ( 'errors.api.members.stripeNotConnected.help' )
} ) ;
}
try {
await membersService . api . members . getStripeCustomer ( entry . stripe _customer _id ) ;
} catch ( error ) {
throw new errors . ValidationError ( {
message : ` Member not imported. ${ error . message } ` ,
context : i18n . t ( 'errors.api.members.stripeCustomerNotFound.context' ) ,
help : i18n . t ( 'errors.api.members.stripeCustomerNotFound.help' )
} ) ;
}
}
} ) ) ;
return null ;
}
} ,
2019-10-03 20:59:19 +03:00
importCSV : {
statusCode : 201 ,
permissions : {
method : 'add'
} ,
async query ( frame ) {
2020-06-12 10:59:36 +03:00
let imported = {
count : 0
} ;
let invalid = {
count : 0 ,
errors : [ ]
} ;
let duplicateStripeCustomerIdCount = 0 ;
2020-02-04 08:51:24 +03:00
2020-08-27 12:47:00 +03:00
let { importSetLabels , importLabel } = await memberLabelsImporter . handleAllLabels (
frame . data . labels ,
frame . data . members ,
settingsCache . get ( 'timezone' ) ,
frame . options
) ;
2020-07-28 13:37:48 +03:00
2020-08-17 14:28:57 +03:00
return Promise . resolve ( ) . then ( async ( ) => {
2020-08-27 12:47:00 +03:00
const { sanitized , invalidCount , validationErrors , duplicateStripeCustomersCount } = await sanitizeInput ( frame . data . members ) ;
2020-08-17 14:28:57 +03:00
invalid . count += invalidCount ;
2020-08-27 12:47:00 +03:00
duplicateStripeCustomerIdCount = duplicateStripeCustomersCount ;
2020-06-12 10:59:36 +03:00
2020-08-17 14:28:57 +03:00
if ( validationErrors . length ) {
invalid . errors . push ( ... validationErrors ) ;
2020-06-12 10:59:36 +03:00
}
2020-02-04 08:51:24 +03:00
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 ( ',' ) ) || [ ] ;
2020-08-27 12:47:00 +03:00
const entryLabels = memberLabelsImporter . 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 ( ) ) {
2020-06-12 10:59:36 +03:00
imported . count = imported . count + 1 ;
2019-10-03 20:59:19 +03:00
} else {
2020-06-09 14:02:38 +03:00
const error = inspection . reason ( ) ;
2020-06-12 10:59:36 +03:00
// 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
if ( Array . isArray ( error ) ) {
logging . error ( error [ 0 ] ) ;
2020-06-19 07:34:23 +03:00
invalid . errors . push ( ... error ) ;
2020-02-10 11:03:08 +03:00
} else {
2020-06-12 10:59:36 +03:00
logging . error ( error ) ;
2020-06-19 07:34:23 +03:00
invalid . errors . push ( error ) ;
2020-02-10 11:03:08 +03:00
}
2020-06-12 10:59:36 +03:00
invalid . count = invalid . count + 1 ;
2019-10-03 20:59:19 +03:00
}
2020-02-10 11:03:08 +03:00
} ) ;
2020-08-25 10:30:02 +03:00
} ) . then ( async ( ) => {
2020-06-12 10:59:36 +03:00
// NOTE: grouping by context because messages can contain unique data like "customer_id"
const groupedErrors = _ . groupBy ( invalid . errors , 'context' ) ;
2020-06-29 15:22:52 +03:00
const uniqueErrors = _ . uniqBy ( invalid . errors , 'context' ) ;
2020-06-12 10:59:36 +03:00
const outputErrors = uniqueErrors . map ( ( error ) => {
let errorGroup = groupedErrors [ error . context ] ;
let errorCount = errorGroup . length ;
if ( error . message === i18n . t ( 'errors.api.members.duplicateStripeCustomerIds.message' ) ) {
errorCount = duplicateStripeCustomerIdCount ;
}
// NOTE: filtering only essential error information, so API doesn't leak more error details than it should
return {
message : error . message ,
2020-08-06 04:58:32 +03:00
context : error . context ,
help : error . help ,
count : errorCount
} ;
} ) ;
invalid . errors = outputErrors ;
2020-08-27 08:27:15 +03:00
if ( imported . count === 0 && importLabel && importLabel . generated ) {
2020-08-25 10:23:05 +03:00
await models . Label . destroy ( Object . assign ( { } , { id : importLabel . id } , frame . options ) ) ;
importLabel = null ;
}
2020-08-06 04:58:32 +03:00
return {
meta : {
stats : {
imported ,
invalid
} ,
import _label : importLabel
}
} ;
} ) ;
}
} ,
importCSVBatched : {
statusCode : 201 ,
permissions : {
method : 'add'
} ,
async query ( frame ) {
let imported = {
count : 0
} ;
let invalid = {
count : 0 ,
errors : [ ]
} ;
let duplicateStripeCustomerIdCount = 0 ;
2020-08-12 11:18:30 +03:00
// NOTE: redacted copy from models.Base module
const contextUser = ( options ) => {
options = options || { } ;
options . context = options . context || { } ;
if ( options . context . user || models . Base . Model . isExternalUser ( options . context . user ) ) {
return options . context . user ;
} else if ( options . context . integration ) {
return models . Base . Model . internalUser ;
}
} ;
const createdBy = contextUser ( frame . options ) ;
2020-08-27 12:47:00 +03:00
let { allLabels , importSetLabels , importLabel } = await memberLabelsImporter . handleAllLabels (
frame . data . labels ,
frame . data . members ,
settingsCache . get ( 'timezone' ) ,
frame . options
) ;
2020-08-06 04:58:32 +03:00
return Promise . resolve ( ) . then ( async ( ) => {
2020-08-27 12:47:00 +03:00
const { sanitized , invalidCount , validationErrors , duplicateStripeCustomersCount } = await sanitizeInput ( frame . data . members ) ;
2020-08-17 14:28:57 +03:00
invalid . count += invalidCount ;
2020-08-27 12:47:00 +03:00
duplicateStripeCustomerIdCount = duplicateStripeCustomersCount ;
2020-08-17 14:28:57 +03:00
if ( validationErrors . length ) {
invalid . errors . push ( ... validationErrors ) ;
2020-08-06 04:58:32 +03:00
}
2020-08-13 11:31:11 +03:00
return doImport ( {
members : sanitized ,
2020-08-27 12:47:00 +03:00
labels : allLabels ,
2020-08-13 11:31:11 +03:00
importSetLabels ,
createdBy
2020-08-06 04:58:32 +03:00
} ) ;
2020-08-25 10:30:02 +03:00
} ) . then ( async ( result ) => {
2020-08-18 15:39:45 +03:00
invalid . errors = invalid . errors . concat ( result . invalid . errors ) ;
invalid . count += result . invalid . count ;
imported . count += result . imported . count ;
2020-08-06 04:58:32 +03:00
// NOTE: grouping by context because messages can contain unique data like "customer_id"
const groupedErrors = _ . groupBy ( invalid . errors , 'context' ) ;
const uniqueErrors = _ . uniqBy ( invalid . errors , 'context' ) ;
const outputErrors = uniqueErrors . map ( ( error ) => {
let errorGroup = groupedErrors [ error . context ] ;
let errorCount = errorGroup . length ;
if ( error . message === i18n . t ( 'errors.api.members.duplicateStripeCustomerIds.message' ) ) {
errorCount = duplicateStripeCustomerIdCount ;
}
// NOTE: filtering only essential error information, so API doesn't leak more error details than it should
return {
message : error . message ,
2020-06-12 10:59:36 +03:00
context : error . context ,
help : error . help ,
count : errorCount
} ;
} ) ;
invalid . errors = outputErrors ;
2020-08-27 08:27:15 +03:00
if ( imported . count === 0 && importLabel && importLabel . generated ) {
2020-08-25 10:23:05 +03:00
await models . Label . destroy ( Object . assign ( { } , { id : importLabel . id } , frame . options ) ) ;
importLabel = null ;
}
2019-10-03 20:59:19 +03:00
return {
meta : {
stats : {
2020-07-22 19:49:41 +03:00
imported ,
invalid
} ,
import _label : importLabel
2019-10-03 20:59:19 +03:00
}
} ;
} ) ;
}
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' ;
2020-06-22 14:21:00 +03:00
const siteTimezone = settingsCache . get ( 'timezone' ) ;
2020-05-26 12:38:42 +03: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 ) {
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
}
} ;
2020-08-11 08:04:49 +03:00
// NOTE: remove below condition once batched import is production ready,
// remember to swap out current importCSV method when doing so
if ( config . get ( 'enableDeveloperExperiments' ) ) {
2020-08-12 16:17:44 +03:00
module . exports . importCSV = module . exports . importCSVBatched ;
delete module . exports . importCSVBatched ;
2020-08-11 08:04:49 +03:00
}