codecombat/server/users/user_handler.coffee
Matt Lott fec3ac38e9 Prepaid subscriptions
Admins can generate a prepaid code, which a user can use to subscribe
for free via the account/subscription page.
The subscription will be identical to the normal monthly subscription
(e.g. 3500 gems per month), except they won’t be charged.
Does not require the recipient to enter billing information.
Can be applied to an existing subscription, which will be converted to
free.
Prepaid code can only be used once.
Prepaid subscription cannot be unsubscribed via the UI.
2015-03-19 15:04:15 -07:00

845 lines
38 KiB
CoffeeScript

schema = require '../../app/schemas/models/user'
crypto = require 'crypto'
request = require 'request'
User = require './User'
Handler = require '../commons/Handler'
mongoose = require 'mongoose'
config = require '../../server_config'
errors = require '../commons/errors'
async = require 'async'
log = require 'winston'
moment = require 'moment'
AnalyticsLogEvent = require '../analytics/AnalyticsLogEvent'
LevelSession = require '../levels/sessions/LevelSession'
LevelSessionHandler = require '../levels/sessions/level_session_handler'
SubscriptionHandler = require '../payments/subscription_handler'
DiscountHandler = require '../payments/discount_handler'
EarnedAchievement = require '../achievements/EarnedAchievement'
UserRemark = require './remarks/UserRemark'
{findStripeSubscription} = require '../lib/utils'
{isID} = require '../lib/utils'
hipchat = require '../hipchat'
sendwithus = require '../sendwithus'
serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP']
candidateProperties = [
'jobProfile', 'jobProfileApproved', 'jobProfileNotes'
]
UserHandler = class UserHandler extends Handler
modelClass: User
allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
getEditableProperties: (req, document) ->
props = super req, document
props.push 'permissions' unless config.isProduction
props.push 'jobProfileApproved', 'jobProfileNotes','jobProfileApprovedDate' if req.user.isAdmin() # Admins naturally edit these
props.push @privateProperties... if req.user.isAdmin() # Admins are mad with power
props
formatEntity: (req, document, publicOnly=false) =>
return null unless document?
obj = document.toObject()
delete obj[prop] for prop in serverProperties
includePrivates = not publicOnly and (req.user and (req.user.isAdmin() or req.user._id.equals(document._id)))
delete obj[prop] for prop in @privateProperties unless includePrivates
includeCandidate = not publicOnly and (includePrivates or (obj.jobProfile?.active and req.user and ('employer' in (req.user.get('permissions') ? [])) and @employerCanViewCandidate req.user, obj))
delete obj[prop] for prop in candidateProperties unless includeCandidate
return obj
waterfallFunctions: [
# FB access token checking
# Check the email is the same as FB reports
(req, user, callback) ->
fbID = req.query.facebookID
fbAT = req.query.facebookAccessToken
return callback(null, req, user) unless fbID and fbAT
url = "https://graph.facebook.com/me?access_token=#{fbAT}"
request(url, (err, response, body) ->
log.warn "Error grabbing FB token: #{err}" if err
body = JSON.parse(body)
emailsMatch = req.body.email is body.email
return callback(res: 'Invalid Facebook Access Token.', code: 422) unless emailsMatch
callback(null, req, user)
)
# GPlus access token checking
(req, user, callback) ->
gpID = req.query.gplusID
gpAT = req.query.gplusAccessToken
return callback(null, req, user) unless gpID and gpAT
url = "https://www.googleapis.com/oauth2/v2/userinfo?access_token=#{gpAT}"
request(url, (err, response, body) ->
log.warn "Error grabbing G+ token: #{err}" if err
body = JSON.parse(body)
emailsMatch = req.body.email is body.email
return callback(res: 'Invalid G+ Access Token.', code: 422) unless emailsMatch
callback(null, req, user)
)
# Email setting
(req, user, callback) ->
return callback(null, req, user) unless req.body.email?
emailLower = req.body.email.toLowerCase()
return callback(null, req, user) if emailLower is user.get('emailLower')
User.findOne({emailLower: emailLower}).exec (err, otherUser) ->
log.error "Database error setting user email: #{err}" if err
return callback(res: 'Database error.', code: 500) if err
if (req.query.gplusID or req.query.facebookID) and otherUser
# special case, log in as that user
return req.logIn(otherUser, (err) ->
return callback(res: 'Facebook user login error.', code: 500) if err
return callback(null, req, otherUser)
)
r = {message: 'is already used by another account', property: 'email'}
return callback({res: r, code: 409}) if otherUser
user.set('email', req.body.email)
callback(null, req, user)
# Name setting
(req, user, callback) ->
return callback(null, req, user) unless req.body.name
nameLower = req.body.name?.toLowerCase()
return callback(null, req, user) unless nameLower
return callback(null, req, user) if user.get 'anonymous' # anonymous users can have any name
return callback(null, req, user) if nameLower is user.get('nameLower')
User.findOne({nameLower: nameLower, anonymous: false}).exec (err, otherUser) ->
log.error "Database error setting user name: #{err}" if err
return callback(res: 'Database error.', code: 500) if err
r = {message: 'is already used by another account', property: 'name'}
console.log 'Another user exists' if otherUser
return callback({res: r, code: 409}) if otherUser
user.set('name', req.body.name)
callback(null, req, user)
# Subscription setting
(req, user, callback) ->
# TODO: Make subscribe vs. unsubscribe explicit. This property dance is confusing.
return callback(null, req, user) unless req.headers['x-change-plan'] # ensure only saves that are targeted at changing the subscription actually affect the subscription
return callback(null, req, user) unless req.body.stripe
finishSubscription = (hasPlan, wantsPlan) ->
return callback(null, req, user) if hasPlan is wantsPlan
if wantsPlan and not hasPlan
SubscriptionHandler.subscribeUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
else if hasPlan and not wantsPlan
SubscriptionHandler.unsubscribeUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
if req.body.stripe.subscribeEmails?
SubscriptionHandler.subscribeUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
else if req.body.stripe.unsubscribeEmail?
SubscriptionHandler.unsubscribeUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
else
wantsPlan = req.body.stripe.planID?
hasPlan = user.get('stripe')?.planID? and not req.body.stripe.prepaidCode?
finishSubscription hasPlan, wantsPlan
# Discount setting
(req, user, callback) ->
return callback(null, req, user) unless req.body.stripe
return callback(null, req, user) unless req.user?.isAdmin()
hasCoupon = user.get('stripe')?.couponID
wantsCoupon = req.body.stripe.couponID
return callback(null, req, user) if hasCoupon is wantsCoupon
if wantsCoupon and (hasCoupon isnt wantsCoupon)
DiscountHandler.discountUser(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
else if hasCoupon and not wantsCoupon
DiscountHandler.removeDiscountFromCustomer(req, user, (err) ->
return callback(err) if err
return callback(null, req, user)
)
]
getById: (req, res, id) ->
if Handler.isID(id) and req.user?._id.equals(id)
return @sendSuccess(res, @formatEntity(req, req.user, 256))
super(req, res, id)
getNamesByIDs: (req, res) ->
ids = req.query.ids or req.body.ids
returnWizard = req.query.wizard or req.body.wizard
properties = if returnWizard then 'name wizard firstName lastName' else 'name firstName lastName'
@getPropertiesFromMultipleDocuments res, User, properties, ids
nameToID: (req, res, name) ->
User.findOne({nameLower: unescape(name).toLowerCase(), anonymous: false}).exec (err, otherUser) ->
res.send(if otherUser then otherUser._id else JSON.stringify(''))
res.end()
getSimulatorLeaderboard: (req, res) ->
queryParameters = @getSimulatorLeaderboardQueryParameters(req)
leaderboardQuery = User.find(queryParameters.query).select('name simulatedBy simulatedFor').sort({'simulatedBy': queryParameters.sortOrder}).limit(queryParameters.limit)
leaderboardQuery.cache() if req.query.scoreOffset is -1
leaderboardQuery.exec (err, otherUsers) ->
otherUsers = _.reject otherUsers, _id: req.user._id if req.query.scoreOffset isnt -1
otherUsers ?= []
res.send(otherUsers)
res.end()
getMySimulatorLeaderboardRank: (req, res) ->
req.query.order = 1
queryParameters = @getSimulatorLeaderboardQueryParameters(req)
User.count queryParameters.query, (err, count) =>
return @sendDatabaseError(res, err) if err
res.send JSON.stringify(count + 1)
getSimulatorLeaderboardQueryParameters: (req) ->
@validateSimulateLeaderboardRequestParameters(req)
query = {}
sortOrder = -1
limit = if req.query.limit > 30 then 30 else req.query.limit
if req.query.scoreOffset isnt -1
simulatedByQuery = {}
simulatedByQuery[if req.query.order is 1 then '$gt' else '$lte'] = req.query.scoreOffset
query.simulatedBy = simulatedByQuery
sortOrder = 1 if req.query.order is 1
else
query.simulatedBy = {'$exists': true}
{query: query, sortOrder: sortOrder, limit: limit}
validateSimulateLeaderboardRequestParameters: (req) ->
req.query.order = parseInt(req.query.order) ? -1
req.query.scoreOffset = parseFloat(req.query.scoreOffset) ? 100000
req.query.limit = parseInt(req.query.limit) ? 20
post: (req, res) ->
return @sendBadInputError(res, 'No input.') if _.isEmpty(req.body)
return @sendBadInputError(res, 'Must have an anonymous user to post with.') unless req.user
return @sendBadInputError(res, 'Existing users cannot create new ones.') if req.user.get('anonymous') is false
req.body._id = req.user._id if req.user.get('anonymous')
@put(req, res)
hasAccessToDocument: (req, document) ->
if req.route.method in ['put', 'post', 'patch', 'delete']
return true if req.user?.isAdmin()
return req.user?._id.equals(document._id)
return true
delete: (req, res, userID) ->
# Instead of just deleting the User object, we should remove all the properties except for _id
# And add a `deleted: true` property
@getDocumentForIdOrSlug userID, (err, user) => # Check first
return @sendDatabaseError res, err if err
return @sendNotFoundError res unless user
return @sendForbiddenError res unless @hasAccessToDocument(req, user)
obj = user.toObject()
for prop, val of obj
user.set(prop, undefined) unless prop is '_id'
user.set('deleted', true)
# Hack to get saving of Users to work. Probably should replace these props with strings
# so that validation doesn't get hung up on Date objects in the documents.
delete obj.dateCreated
user.save (err) =>
return @sendDatabaseError(res, err) if err
@sendNoContent res
getByRelationship: (req, res, args...) ->
return @agreeToCLA(req, res) if args[1] is 'agreeToCLA'
return @agreeToEmployerAgreement(req, res) if args[1] is 'agreeToEmployerAgreement'
return @avatar(req, res, args[0]) if args[1] is 'avatar'
return @getNamesByIDs(req, res) if args[1] is 'names'
return @nameToID(req, res, args[0]) if args[1] is 'nameToID'
return @getLevelSessionsForEmployer(req, res, args[0]) if args[1] is 'level.sessions' and args[2] is 'employer'
return @getLevelSessions(req, res, args[0]) if args[1] is 'level.sessions'
return @getCandidates(req, res) if args[1] is 'candidates'
return @getEmployers(req, res) if args[1] is 'employers'
return @getSimulatorLeaderboard(req, res, args[0]) if args[1] is 'simulatorLeaderboard'
return @getMySimulatorLeaderboardRank(req, res, args[0]) if args[1] is 'simulator_leaderboard_rank'
return @getEarnedAchievements(req, res, args[0]) if args[1] is 'achievements'
return @getRecentlyPlayed(req, res, args[0]) if args[1] is 'recently_played'
return @trackActivity(req, res, args[0], args[2], args[3]) if args[1] is 'track' and args[2]
return @getRemark(req, res, args[0]) if args[1] is 'remark'
return @searchForUser(req, res) if args[1] is 'admin_search'
return @getStripeInfo(req, res, args[0]) if args[1] is 'stripe'
return @getSubRecipients(req, res) if args[1] is 'sub_recipients'
return @getSubSponsor(req, res) if args[1] is 'sub_sponsor'
return @sendOneTimeEmail(req, res, args[0]) if args[1] is 'send_one_time_email'
return @sendNotFoundError(res)
super(arguments...)
getStripeInfo: (req, res, handle) ->
@getDocumentForIdOrSlug handle, (err, user) =>
return @sendNotFoundError(res) if not user
return @sendForbiddenError(res) unless req.user and (req.user.isAdmin() or req.user.get('_id').equals(user.get('_id')))
return @sendNotFoundError(res) if not customerID = user.get('stripe')?.customerID
stripe.customers.retrieve customerID, (err, customer) =>
return @sendDatabaseError(res, err) if err
info = card: customer.sources?.data?[0]
findStripeSubscription customerID, subscriptionID: user.get('stripe').subscriptionID, (subscription) =>
info.subscription = subscription
findStripeSubscription customerID, subscriptionID: user.get('stripe').sponsorSubscriptionID, (subscription) =>
info.sponsorSubscription = subscription
@sendSuccess(res, JSON.stringify(info, null, '\t'))
getSubRecipients: (req, res) ->
# Return map of userIDs to name/email/cancel date
# TODO: Add test for this API
return @sendSuccess(res, {}) if _.isEmpty(req.user?.get('stripe')?.recipients ? [])
return @sendSuccess(res, {}) unless req.user.get('stripe')?.customerID?
# Get recipients User info
ids = (recipient.userID for recipient in req.user.get('stripe').recipients)
User.find({'_id': { $in: ids} }, 'name emailLower').exec (err, users) =>
info = {}
_.each users, (user) -> info[user.id] = user.toObject()
customerID = req.user.get('stripe').customerID
nextBatch = (starting_after, done) ->
options = limit: 100
options.starting_after = starting_after if starting_after
stripe.customers.listSubscriptions customerID, options, (err, subscriptions) ->
return done(err) if err
return done() unless subscriptions?.data?.length > 0
for sub in subscriptions.data
userID = sub.metadata?.id
continue unless userID of info
if sub.cancel_at_period_end and info[userID]['cancel_at_period_end'] isnt false
info[userID]['cancel_at_period_end'] = new Date(sub.current_period_end * 1000)
else
info[userID]['cancel_at_period_end'] = false
if subscriptions.has_more
return nextBatch(subscriptions.data[subscriptions.data.length - 1].id, done)
else
return done()
nextBatch null, (err) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, info)
getSubSponsor: (req, res) ->
# TODO: Add test for this API
return @sendSuccess(res, {}) unless req.user?.get('stripe')?.sponsorID?
# Get sponsor User info
User.findById req.user.get('stripe').sponsorID, (err, sponsor) =>
return @sendDatabaseError(res, err) if err
return @sendDatabaseError(res, 'No sponsor customerID') unless sponsor?.get('stripe')?.customerID?
info =
email: sponsor.get('emailLower')
name: sponsor.get('name')
# Get recipient subscription info
findStripeSubscription sponsor.get('stripe').customerID, userID: req.user.id, (subscription) =>
info.subscription = subscription
@sendDatabaseError(res, 'No sponsored subscription found') unless info.subscription?
@sendSuccess(res, info)
sendOneTimeEmail: (req, res) ->
# TODO: Should this API be somewhere else?
# TODO: Where should email types be stored?
# TODO: How do we schema validate an update db call?
return @sendForbiddenError(res) unless req.user
email = req.query.email or req.body.email
type = req.query.type or req.body.type
return @sendBadInputError res, 'No email given.' unless email?
return @sendBadInputError res, 'No type given.' unless type?
# log.warn "sendOneTimeEmail #{type} #{email}"
unless type in ['subscribe modal parent', 'share progress modal parent', 'share progress modal friend']
return @sendBadInputError res, "Unknown one-time email type #{type}"
sendMail = (emailParams) =>
sendwithus.api.send emailParams, (err, result) =>
if err
log.error "sendwithus one-time email error: #{err}, result: #{result}"
return @sendError res, 500, 'send mail failed.'
req.user.update {$push: {"emails.oneTimes": {type: type, email: email, sent: new Date()}}}, (err) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, {result: 'success'})
AnalyticsLogEvent.logEvent req.user, 'Sent one time email', email: email, type: type
# Generic email data
emailParams =
recipient:
address: email
email_data:
name: req.user.get('name') or ''
if codeLanguage = req.user.get('aceConfig.language')
codeLanguage = codeLanguage[0].toUpperCase() + codeLanguage.slice(1)
emailParams['email_data']['codeLanguage'] = codeLanguage
if senderEmail = req.user.get('email')
emailParams['email_data']['senderEmail'] = senderEmail
# Type-specific email data
if type is 'subscribe modal parent'
emailParams['email_id'] = sendwithus.templates.parent_subscribe_email
else if type in ['share progress modal parent', 'share progress modal friend']
emailParams['email_id'] = sendwithus.templates.share_progress_email
emailParams['email_data']['premium'] = req.user.isPremium()
emailParams['email_data']['parent'] = type is 'share progress modal parent'
emailParams['email_data']['friend'] = type is 'share progress modal friend'
sendMail emailParams
agreeToCLA: (req, res) ->
return @sendForbiddenError(res) unless req.user
doc =
user: req.user._id+''
email: req.user.get 'email'
name: req.user.get 'name'
githubUsername: req.body.githubUsername
created: new Date()+''
collection = mongoose.connection.db.collection 'cla.submissions', (err, collection) =>
return @sendDatabaseError(res, err) if err
collection.insert doc, (err) =>
return @sendDatabaseError(res, err) if err
req.user.set('signedCLA', doc.created)
req.user.save (err) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, {result: 'success'})
hipchat.sendHipChatMessage "#{req.body.githubUsername or req.user.get('name')} just signed the CLA.", ['main']
avatar: (req, res, id) ->
@modelClass.findById(id).exec (err, document) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless document
photoURL = document?.get('photoURL')
if photoURL
photoURL = "/file/#{photoURL}"
else if req.query.employerPageAvatar is "true"
photoURL = @buildGravatarURL document, req.query.s, "/images/pages/employer/anon_user.png"
else
photoURL = @buildGravatarURL document, req.query.s, req.query.fallback
res.redirect photoURL
res.end()
getLevelSessionsForEmployer: (req, res, userID) ->
return @sendForbiddenError(res) unless req.user
return @sendForbiddenError(res) unless req.user._id+'' is userID or req.user.isAdmin() or ('employer' in (req.user.get('permissions') ? []))
query = creator: userID, levelID: {$in: ['criss-cross', 'gridmancer', 'greed', 'dungeon-arena', 'brawlwood', 'gold-rush']}
projection = 'levelName levelID team playtime codeLanguage submitted code totalScore teamSpells level'
LevelSession.find(query).select(projection).exec (err, documents) =>
return @sendDatabaseError(res, err) if err
documents = (LevelSessionHandler.formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents)
IDify: (idOrSlug, done) ->
return done null, idOrSlug if Handler.isID idOrSlug
User.findBySlug idOrSlug, (err, user) -> done err, user?.get '_id'
getLevelSessions: (req, res, userIDOrSlug) ->
@IDify userIDOrSlug, (err, userID) =>
return @sendDatabaseError res, err if err
return @sendNotFoundError res unless userID?
query = creator: userID + ''
isAuthorized = req.user?._id+'' is userID or req.user?.isAdmin()
projection = {}
if req.query.project
projection[field] = 1 for field in req.query.project.split(',') when isAuthorized or not (field in LevelSessionHandler.privateProperties)
else unless isAuthorized
projection[field] = 0 for field in LevelSessionHandler.privateProperties
sort = {}
sort.changed = req.query.order if req.query.order
LevelSession.find(query).select(projection).sort(sort).exec (err, documents) =>
return @sendDatabaseError(res, err) if err
documents = (LevelSessionHandler.formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents)
getEarnedAchievements: (req, res, userIDOrSlug) ->
@IDify userIDOrSlug, (err, userID) =>
return @sendDatabaseError res, err if err
return @sendNotFoundError res unless userID?
query = user: userID + ''
query.notified = false if req.query.notified is 'false'
EarnedAchievement.find(query).sort(changed: -1).exec (err, documents) =>
return @sendDatabaseError(res, err) if err?
cleandocs = (@formatEntity(req, doc) for doc in documents)
@sendSuccess(res, cleandocs)
getRecentlyPlayed: (req, res, userID) ->
twoWeeksAgo = moment().subtract('days', 14).toDate()
LevelSession.find(creator: userID, changed: $gt: twoWeeksAgo).sort(changed: -1).exec (err, docs) =>
return @sendDatabaseError res, err if err?
cleandocs = (@formatEntity(req, doc) for doc in docs)
@sendSuccess res, cleandocs
trackActivity: (req, res, userID, activityName, increment=1) ->
return @sendMethodNotAllowed res unless req.method is 'POST'
isMe = userID is req.user?._id + ''
isAuthorized = isMe or req.user?.isAdmin()
isAuthorized ||= ('employer' in (req.user?.get('permissions') ? [])) and (activityName in ['viewed_by_employer', 'contacted_by_employer'])
return @sendForbiddenError res unless isAuthorized
updateUser = (user) =>
activity = user.trackActivity activityName, increment
user.update {activity: activity}, (err) =>
return @sendDatabaseError res, err if err
@sendSuccess res, result: 'success'
if isMe
updateUser(req.user)
else
@getDocumentForIdOrSlug userID, (err, user) =>
return @sendDatabaseError res, err if err
return @sendNotFoundError res unless user
updateUser user
agreeToEmployerAgreement: (req, res) ->
userIsAnonymous = req.user?.get('anonymous')
if userIsAnonymous then return errors.unauthorized(res, 'You need to be logged in to agree to the employer agreeement.')
profileData = req.body
#TODO: refactor this bit to make it more elegant
if not profileData.id or not profileData.positions or not profileData.emailAddress or not profileData.firstName or not profileData.lastName
return errors.badInput(res, 'You need to have a more complete profile to sign up for this service.')
@modelClass.findById(req.user.id).exec (err, user) =>
if user.get('employerAt') or user.get('signedEmployerAgreement') or 'employer' in (user.get('permissions') ? [])
return errors.conflict(res, 'You already have signed the agreement!')
#TODO: Search for the current position
employerAt = _.filter(profileData.positions.values, 'isCurrent')[0]?.company.name ? 'Not available'
signedEmployerAgreement =
linkedinID: profileData.id
date: new Date()
data: profileData
updateObject =
'employerAt': employerAt
'signedEmployerAgreement': signedEmployerAgreement
$push: 'permissions': 'employer'
User.update {'_id': req.user.id}, updateObject, (err, result) =>
if err? then return errors.serverError(res, "There was an issue updating the user object to reflect employer status: #{err}")
res.send({'message': 'The agreement was successful.'})
res.end()
getCandidates: (req, res) ->
return @sendForbiddenError(res) unless req.user
return @sendForbiddenError(res) # No one can view the candidates, since in a rush, we deleted their index!
authorized = req.user.isAdmin() or ('employer' in (req.user.get('permissions') ? []))
months = if req.user.isAdmin() then 12 else 2
since = (new Date((new Date()) - months * 30.4 * 86400 * 1000)).toISOString()
query = {'jobProfile.updated': {$gt: since}}
query['jobProfile.active'] = true unless req.user.isAdmin()
selection = 'jobProfile jobProfileApproved photoURL'
selection += ' email name' if authorized
User.find(query).select(selection).exec (err, documents) =>
return @sendDatabaseError(res, err) if err
candidates = (candidate for candidate in documents when @employerCanViewCandidate req.user, candidate.toObject())
candidates = (@formatCandidate(authorized, candidate) for candidate in candidates)
@sendSuccess(res, candidates)
formatCandidate: (authorized, document) ->
fields = if authorized then ['name', 'jobProfile', 'jobProfileApproved', 'photoURL', '_id'] else ['_id','jobProfile', 'jobProfileApproved']
obj = _.pick document.toObject(), fields
obj.photoURL ||= obj.jobProfile.photoURL #if authorized
subfields = ['country', 'city', 'lookingFor', 'jobTitle', 'skills', 'experience', 'updated', 'active', 'shortDescription', 'curated', 'visa']
if authorized
subfields = subfields.concat ['name']
obj.jobProfile = _.pick obj.jobProfile, subfields
obj
employerCanViewCandidate: (employer, candidate) ->
return true if employer.isAdmin()
for job in candidate.jobProfile?.work ? []
# TODO: be smarter about different ways to write same company names to ensure privacy.
# We'll have to manually pay attention to how we set employer names for now.
if job.employer?.toLowerCase() is employer.get('employerAt')?.toLowerCase()
log.info "#{employer.get('name')} at #{employer.get('employerAt')} can't see #{candidate.jobProfile.name} because s/he worked there."
return false if job.employer?.toLowerCase() is employer.get('employerAt')?.toLowerCase()
true
getEmployers: (req, res) ->
return @sendForbiddenError(res) unless req.user?.isAdmin()
return @sendForbiddenError(res) # No one can view the employers, since in a rush, we deleted their index!
query = {employerAt: {$exists: true, $ne: ''}}
selection = 'name firstName lastName email activity signedEmployerAgreement photoURL employerAt'
User.find(query).select(selection).lean().exec (err, documents) =>
return @sendDatabaseError res, err if err
@sendSuccess res, documents
buildGravatarURL: (user, size, fallback) ->
emailHash = @buildEmailHash user
fallback ?= 'http://codecombat.com/file/db/thang.type/52a00d55cf1818f2be00000b/portrait.png'
fallback = "http://codecombat.com#{fallback}" unless /^http/.test fallback
"https://www.gravatar.com/avatar/#{emailHash}?s=#{size}&default=#{fallback}"
buildEmailHash: (user) ->
# emailHash is used by gravatar
hash = crypto.createHash('md5')
if user.get('email')
hash.update(_.trim(user.get('email')).toLowerCase())
else
hash.update(user.get('_id') + '')
hash.digest('hex')
getRemark: (req, res, userID) ->
return @sendForbiddenError(res) unless req.user?.isAdmin()
query = user: userID
projection = null
if req.query.project
projection = {}
projection[field] = 1 for field in req.query.project.split(',')
UserRemark.findOne(query).select(projection).exec (err, remark) =>
return @sendDatabaseError res, err if err
return @sendNotFoundError res unless remark?
@sendSuccess res, remark
searchForUser: (req, res) ->
# TODO: also somehow search the CLAs to find a match amongst those fields and to find GitHub ids
return @sendForbiddenError(res) unless req.user?.isAdmin()
search = req.body.search
query = email: {$exists: true}, $or: [
{emailLower: search}
{nameLower: search}
]
query.$or.push {_id: mongoose.Types.ObjectId(search) if isID search}
if search.length > 5
searchParts = search.split(/[.+@]/)
if searchParts.length > 1
query.$or.push {emailLower: {$regex: '^' + searchParts[0]}}
projection = name: 1, email: 1, dateCreated: 1
User.find(query).select(projection).lean().exec (err, users) =>
return @sendDatabaseError res, err if err
@sendSuccess res, users
countEdits = (model, done) ->
statKey = User.statsMapping.edits[model.modelName]
return done(new Error 'Could not resolve statKey for model') unless statKey?
userStream = User.find({anonymous: false}).sort('_id').stream()
streamFinished = false
usersTotal = 0
usersFinished = 0
numberRunning = 0
doneWithUser = (err) ->
log.error err if err?
++usersFinished
--numberRunning
userStream.resume()
done?() if streamFinished and usersFinished is usersTotal
userStream.on 'error', (err) -> log.error err
userStream.on 'close', -> streamFinished = true
userStream.on 'data', (user) ->
++usersTotal
++numberRunning
userStream.pause() if numberRunning > 20
userObjectID = user.get('_id')
userStringID = userObjectID.toHexString()
model.count {$or: [creator: userObjectID, creator: userStringID]}, (err, count) ->
if count
update = $set: {}
update.$set[statKey] = count
else
update = $unset: {}
update.$unset[statKey] = ''
console.log "... updating #{userStringID} patches #{statKey} to #{count}, #{usersTotal} players found so far." if count
User.findByIdAndUpdate user.get('_id'), update, (err) ->
log.error err if err?
doneWithUser()
# I don't like leaking big variables, could remove this for readability
# Meant for passing into MongoDB
{isMiscPatch, isTranslationPatch} = do ->
deltas = require '../../app/core/deltas'
isMiscPatch: (obj) ->
expanded = deltas.flattenDelta obj.get 'delta'
_.some expanded, (delta) -> 'i18n' not in delta.dataPath
isTranslationPatch: (obj) ->
expanded = deltas.flattenDelta obj.get 'delta'
_.some expanded, (delta) -> 'i18n' in delta.dataPath
Patch = require '../patches/Patch'
# filter is passed a mongoose document and should return a boolean,
# determining whether the patch should be counted
countPatchesByUsersInMemory = (query, filter, statName, done) ->
updateUser = (user, count, doneUpdatingUser) ->
method = if count then '$set' else '$unset'
update = {}
update[method] = {}
update[method][statName] = count or ''
console.log "... updating #{user.get('_id')} patches #{JSON.stringify(query)} #{statName} to #{count}, #{usersTotal} players found so far." if count
User.findByIdAndUpdate user.get('_id'), update, doneUpdatingUser
userStream = User.find({anonymous: false}).sort('_id').stream()
streamFinished = false
usersTotal = 0
usersFinished = 0
numberRunning = 0
doneWithUser = (err) ->
log.error err if err?
++usersFinished
--numberRunning
userStream.resume()
done?() if streamFinished and usersFinished is usersTotal
userStream.on 'error', (err) -> log.error err
userStream.on 'close', -> streamFinished = true
userStream.on 'data', (user) ->
++usersTotal
++numberRunning
userStream.pause() if numberRunning > 20
userObjectID = user.get '_id'
userStringID = userObjectID.toHexString()
# Extend query with a patch ownership test
_.extend query, {$or: [{creator: userObjectID}, {creator: userStringID}]}
count = 0
stream = Patch.where(query).stream()
stream.on 'data', (doc) -> ++count if filter doc
stream.on 'error', (err) ->
updateUser user, count, doneWithUser
log.error "Recalculating #{statName} for user #{user} stopped prematurely because of error"
stream.on 'close', ->
updateUser user, count, doneWithUser
countPatchesByUsers = (query, statName, done) ->
Patch = require '../patches/Patch'
userStream = User.find({anonymous: false}).sort('_id').stream()
streamFinished = false
usersTotal = 0
usersFinished = 0
numberRunning = 0
doneWithUser = (err) ->
log.error err if err?
++usersFinished
--numberRunning
userStream.resume()
done?() if streamFinished and usersFinished is usersTotal
userStream.on 'error', (err) -> log.error err
userStream.on 'close', -> streamFinished = true
userStream.on 'data', (user) ->
++usersTotal
++numberRunning
userStream.pause() if numberRunning > 20
userObjectID = user.get '_id'
userStringID = userObjectID.toHexString()
# Extend query with a patch ownership test
_.extend query, {$or: [{creator: userObjectID}, {creator: userStringID}]}
Patch.count query, (err, count) ->
method = if count then '$set' else '$unset'
update = {}
update[method] = {}
update[method][statName] = count or ''
console.log "... updating #{userStringID} patches #{query} to #{count}, #{usersTotal} players found so far." if count
User.findByIdAndUpdate user.get('_id'), update, doneWithUser
statRecalculators:
gamesCompleted: (done) ->
LevelSession = require '../levels/sessions/LevelSession'
userStream = User.find({anonymous: false}).sort('_id').stream()
streamFinished = false
usersTotal = 0
usersFinished = 0
numberRunning = 0
doneWithUser = (err) ->
log.error err if err?
++usersFinished
--numberRunning
userStream.resume()
if streamFinished and usersFinished is usersTotal
console.log "----------- Finished recalculating statistics for gamesCompleted for #{usersFinished} players. -----------"
done?()
userStream.on 'error', (err) -> log.error err
userStream.on 'close', -> streamFinished = true
userStream.on 'data', (user) ->
++usersTotal
++numberRunning
userStream.pause() if numberRunning > 20
userID = user.get('_id').toHexString()
LevelSession.count {creator: userID, 'state.complete': true}, (err, count) ->
update = if count then {$set: 'stats.gamesCompleted': count} else {$unset: 'stats.gamesCompleted': ''}
console.log "... updating #{userID} gamesCompleted to #{count}, #{usersTotal} players found so far." if Math.random() < 0.001
User.findByIdAndUpdate user.get('_id'), update, doneWithUser
articleEdits: (done) ->
Article = require '../articles/Article'
countEdits Article, done
levelEdits: (done) ->
Level = require '../levels/Level'
countEdits Level, done
levelComponentEdits: (done) ->
LevelComponent = require '../levels/components/LevelComponent'
countEdits LevelComponent, done
levelSystemEdits: (done) ->
LevelSystem = require '../levels/systems/LevelSystem'
countEdits LevelSystem, done
thangTypeEdits: (done) ->
ThangType = require '../levels/thangs/ThangType'
countEdits ThangType, done
patchesContributed: (done) ->
countPatchesByUsers {'status': 'accepted'}, 'stats.patchesContributed', done
patchesSubmitted: (done) ->
countPatchesByUsers {}, 'stats.patchesSubmitted', done
# The below need functions for filtering and are thus checked in memory
totalTranslationPatches: (done) ->
countPatchesByUsersInMemory {}, isTranslationPatch, 'stats.totalTranslationPatches', done
totalMiscPatches: (done) ->
countPatchesByUsersInMemory {}, isMiscPatch, 'stats.totalMiscPatches', done
articleMiscPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'article'}, isMiscPatch, User.statsMapping.misc.article, done
levelMiscPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'level'}, isMiscPatch, User.statsMapping.misc.level, done
levelComponentMiscPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'level_component'}, isMiscPatch, User.statsMapping.misc['level.component'], done
levelSystemMiscPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'level_system'}, isMiscPatch, User.statsMapping.misc['level.system'], done
thangTypeMiscPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'thang_type'}, isMiscPatch, User.statsMapping.misc['thang.type'], done
articleTranslationPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'article'}, isTranslationPatch, User.statsMapping.translations.article, done
levelTranslationPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'level'}, isTranslationPatch, User.statsMapping.translations.level, done
levelComponentTranslationPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'level_component'}, isTranslationPatch, User.statsMapping.translations['level.component'], done
levelSystemTranslationPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'level_system'}, isTranslationPatch, User.statsMapping.translations['level.system'], done
thangTypeTranslationPatches: (done) ->
countPatchesByUsersInMemory {'target.collection': 'thang_type'}, isTranslationPatch, User.statsMapping.translations['thang.type'], done
recalculateStats: (statName, done) =>
done new Error 'Recalculation handler not found' unless statName of @statRecalculators
@statRecalculators[statName] done
recalculate: (req, res, statName) ->
return @sendForbiddenError(res) unless req.user?.isAdmin()
log.debug 'recalculate'
return @sendNotFoundError(res) unless statName of @statRecalculators
@recalculateStats statName
@sendAccepted res, {}
module.exports = new UserHandler()