mongoose = require 'mongoose' jsonschema = require '../../app/schemas/models/user' crypto = require 'crypto' {salt, isProduction} = require '../../server_config' mail = require '../commons/mail' log = require 'winston' plugins = require '../plugins/plugins' AnalyticsUsersActive = require './AnalyticsUsersActive' Classroom = require '../models/Classroom' languages = require '../routes/languages' _ = require 'lodash' errors = require '../commons/errors' Promise = require 'bluebird' config = require '../../server_config' stripe = require('stripe')(config.stripe.secretKey) sendwithus = require '../sendwithus' UserSchema = new mongoose.Schema({ dateCreated: type: Date 'default': Date.now }, {strict: false}) UserSchema.index({'dateCreated': 1}) UserSchema.index({'emailLower': 1}, {unique: true, sparse: true, name: 'emailLower_1'}) UserSchema.index({'facebookID': 1}, {sparse: true}) UserSchema.index({'gplusID': 1}, {sparse: true}) UserSchema.index({'cleverID': 1}, {sparse: true}) UserSchema.index({'iosIdentifierForVendor': 1}, {name: 'iOS identifier for vendor', sparse: true, unique: true}) UserSchema.index({'mailChimp.leid': 1}, {sparse: true}) UserSchema.index({'nameLower': 1}, {sparse: true, name: 'nameLower_1'}) UserSchema.index({'simulatedBy': 1}) UserSchema.index({'slug': 1}, {name: 'slug index', sparse: true, unique: true}) UserSchema.index({'stripe.subscriptionID': 1}, {unique: true, sparse: true}) UserSchema.index({'siteref': 1}, {name: 'siteref index', sparse: true}) UserSchema.index({'schoolName': 1}, {name: 'schoolName index', sparse: true}) UserSchema.index({'country': 1}, {name: 'country index', sparse: true}) UserSchema.index({'role': 1}, {name: 'role index', sparse: true}) UserSchema.index({'coursePrepaid._id': 1}, {name: 'course prepaid id index', sparse: true}) UserSchema.post('init', -> @set('anonymous', false) if @get('email') ) UserSchema.methods.broadName = -> return '(deleted)' if @get('deleted') name = _.filter([@get('firstName'), @get('lastName')]).join(' ') return name if name name = @get('name') return name if name [emailName, emailDomain] = @get('email').split('@') return emailName if emailName return 'Anonymous' UserSchema.methods.isInGodMode = -> p = @get('permissions') return p and 'godmode' in p UserSchema.methods.isAdmin = -> p = @get('permissions') return p and 'admin' in p UserSchema.methods.hasPermission = (neededPermissions) -> permissions = @get('permissions') or [] if _.contains(permissions, 'admin') return true if _.isString(neededPermissions) neededPermissions = [neededPermissions] return _.size(_.intersection(permissions, neededPermissions)) UserSchema.methods.isArtisan = -> p = @get('permissions') return p and 'artisan' in p UserSchema.methods.isAnonymous = -> @get 'anonymous' UserSchema.statics.teacherRoles = ['teacher', 'technology coordinator', 'advisor', 'principal', 'superintendent', 'parent'] UserSchema.methods.isTeacher = -> return @get('role') in User.teacherRoles UserSchema.methods.isStudent = -> return @get('role') is 'student' UserSchema.methods.getUserInfo = -> id: @get('_id') email: if @get('anonymous') then 'Unregistered User' else @get('email') UserSchema.methods.removeFromClassrooms = -> userID = @get('_id') yield Classroom.update( { members: userID } { $addToSet: { deletedMembers: userID } $pull: { members: userID } } { multi: true } ) UserSchema.methods.trackActivity = (activityName, increment) -> now = new Date() increment ?= parseInt increment or 1 increment = Math.max increment, 0 activity = @get('activity') ? {} activity[activityName] ?= {first: now, count: 0} activity[activityName].count += increment activity[activityName].last = now @set 'activity', activity activity UserSchema.statics.search = (term, done) -> utils = require '../lib/utils' if utils.isID(term) query = {_id: mongoose.Types.ObjectId(term)} else term = term.toLowerCase() query = $or: [{nameLower: term}, {emailLower: term}] return User.findOne(query).exec(done) UserSchema.statics.findByEmail = (email, done=_.noop) -> emailLower = email.toLowerCase() User.findOne({emailLower: emailLower}).exec(done) UserSchema.statics.findByName = (name, done=_.noop) -> nameLower = name.toLowerCase() User.findOne({nameLower: nameLower}).exec(done) emailNameMap = generalNews: 'announcement' adventurerNews: 'tester' artisanNews: 'level_creator' archmageNews: 'developer' scribeNews: 'article_editor' diplomatNews: 'translator' ambassadorNews: 'support' anyNotes: 'notification' teacherNews: 'teacher' UserSchema.methods.setEmailSubscription = (newName, enabled) -> oldSubs = _.clone @get('emailSubscriptions') if oldSubs and oldName = emailNameMap[newName] oldSubs = (s for s in oldSubs when s isnt oldName) oldSubs.push(oldName) if enabled @set('emailSubscriptions', oldSubs) newSubs = _.clone(@get('emails') or _.cloneDeep(jsonschema.properties.emails.default)) newSubs[newName] ?= {} newSubs[newName].enabled = enabled @set('emails', newSubs) @newsSubsChanged = true if newName in mail.NEWS_GROUPS UserSchema.methods.gems = -> gemsEarned = @get('earned')?.gems ? 0 gemsEarned = gemsEarned + 100000 if @isInGodMode() gemsPurchased = @get('purchased')?.gems ? 0 gemsSpent = @get('spent') ? 0 gemsEarned + gemsPurchased - gemsSpent UserSchema.methods.isEmailSubscriptionEnabled = (newName) -> emails = @get 'emails' if not emails oldSubs = @get('emailSubscriptions') oldName = emailNameMap[newName] return oldName and oldName in oldSubs if oldSubs emails ?= {} _.defaults emails, _.cloneDeep(jsonschema.properties.emails.default) return emails[newName]?.enabled UserSchema.statics.updateServiceSettings = (doc, callback) -> return callback?() unless isProduction or GLOBAL.testing return callback?() if doc.updatedMailChimp return callback?() unless doc.get('email') return callback?() unless doc.get('dateCreated') accountAgeMinutes = (new Date().getTime() - doc.get('dateCreated').getTime?() ? 0) / 1000 / 60 return callback?() unless accountAgeMinutes > 30 or GLOBAL.testing existingProps = doc.get('mailChimp') emailChanged = (not existingProps) or existingProps?.email isnt doc.get('email') if emailChanged and customerID = doc.get('stripe')?.customerID unless stripe?.customers console.error('Oh my god, Stripe is not imported correctly-how could we have done this (again)?') stripe?.customers?.update customerID, {email:doc.get('email')}, (err, customer) -> console.error('Error updating stripe customer...', err) if err return callback?() unless emailChanged or doc.newsSubsChanged newGroups = [] for [mailchimpEmailGroup, emailGroup] in _.zip(mail.MAILCHIMP_GROUPS, mail.NEWS_GROUPS) newGroups.push(mailchimpEmailGroup) if doc.isEmailSubscriptionEnabled(emailGroup) if (not existingProps) and newGroups.length is 0 return callback?() # don't add totally unsubscribed people to the list params = {} params.id = mail.MAILCHIMP_LIST_ID params.email = if existingProps then {leid: existingProps.leid} else {email: doc.get('email')} params.merge_vars = { groupings: [{id: mail.MAILCHIMP_GROUP_ID, groups: newGroups}] 'new-email': doc.get('email') } params.update_existing = true onSuccess = (data) -> data.email = doc.get('email') # Make sure that we don't spam opt-in emails even if MailChimp doesn't update the email it gets in this object until they have confirmed. doc.set('mailChimp', data) doc.updatedMailChimp = true doc.save() callback?() onFailure = (error) -> log.error 'failed to subscribe', error, callback? doc.updatedMailChimp = true callback?() mc?.lists.subscribe params, onSuccess, onFailure UserSchema.statics.statsMapping = edits: article: 'stats.articleEdits' level: 'stats.levelEdits' 'level.component': 'stats.levelComponentEdits' 'level_component': 'stats.levelComponentEdits' 'level.system': 'stats.levelSystemEdits' 'level_system': 'stats.levelSystemEdits' 'thang.type': 'stats.thangTypeEdits' 'thang_type': 'stats.thangTypeEdits' 'Achievement': 'stats.achievementEdits' 'achievement': 'stats.achievementEdits' 'campaign': 'stats.campaignEdits' 'poll': 'stats.pollEdits' 'course': 'stats.courseEdits' translations: article: 'stats.articleTranslationPatches' level: 'stats.levelTranslationPatches' 'level.component': 'stats.levelComponentTranslationPatches' 'level_component': 'stats.levelComponentTranslationPatches' 'level.system': 'stats.levelSystemTranslationPatches' 'level_system': 'stats.levelSystemTranslationPatches' 'thang.type': 'stats.thangTypeTranslationPatches' 'thang_type': 'stats.thangTypeTranslationPatches' 'Achievement': 'stats.achievementTranslationPatches' 'achievement': 'stats.achievementTranslationPatches' 'campaign': 'stats.campaignTranslationPatches' 'poll': 'stats.pollTranslationPatches' 'course': 'stats.courseTranslationPatches' misc: article: 'stats.articleMiscPatches' level: 'stats.levelMiscPatches' 'level.component': 'stats.levelComponentMiscPatches' 'level_component': 'stats.levelComponentMiscPatches' 'level.system': 'stats.levelSystemMiscPatches' 'level_system': 'stats.levelSystemMiscPatches' 'thang.type': 'stats.thangTypeMiscPatches' 'thang_type': 'stats.thangTypeMiscPatches' 'Achievement': 'stats.achievementMiscPatches' 'achievement': 'stats.achievementMiscPatches' 'campaign': 'stats.campaignMiscPatches' 'poll': 'stats.pollMiscPatches' 'course': 'stats.courseMiscPatches' # TODO: Migrate from incrementStat to incrementStatAsync UserSchema.statics.incrementStatAsync = Promise.promisify (id, statName, options={}, done) -> # A shim over @incrementStat, providing a Promise interface if _.isFunction(options) done = options options = {} @incrementStat(id, statName, done, options.inc or 1) UserSchema.statics.incrementStat = (id, statName, done=_.noop, inc=1) -> _id = if _.isString(id) then mongoose.Types.ObjectId(id) else id update = {$inc: {}} update.$inc[statName] = inc @update({_id}, update).exec((err, res) -> if not res.nModified log.warn "Did not update user stat '#{statName}' for '#{id}'" done?() ) UserSchema.methods.incrementStat = (statName, done, inc=1) -> if /^concepts\./.test statName # Concept stats are nested a level deeper. concepts = @get('concepts') or {} concept = statName.split('.')[1] concepts[concept] = (concepts[concept] or 0) + inc @set 'concepts', concepts else @set statName, (@get(statName) or 0) + inc @save (err) -> done?(err) UserSchema.statics.unconflictName = unconflictName = (name, done) -> User.findOne {slug: _.str.slugify(name)}, (err, otherUser) -> return done err if err? return done null, name unless otherUser suffix = _.random(0, 9) + '' unconflictName name + suffix, done UserSchema.statics.unconflictNameAsync = Promise.promisify(unconflictName) UserSchema.methods.sendWelcomeEmail = -> return if not @get('email') { welcome_email_student, welcome_email_user } = sendwithus.templates timestamp = (new Date).getTime() data = email_id: if @isStudent() then welcome_email_student else welcome_email_user recipient: address: @get('email') name: @broadName() email_data: name: @broadName() verify_link: "http://codecombat.com/user/#{@_id}/verify/#{@verificationCode(timestamp)}" sendwithus.api.send data, (err, result) -> log.error "sendwithus post-save error: #{err}, result: #{result}" if err UserSchema.methods.hasSubscription = -> return false unless stripeObject = @get('stripe') return true if stripeObject.sponsorID return true if stripeObject.subscriptionID return true if stripeObject.free is true return true if _.isString(stripeObject.free) and new Date() < new Date(stripeObject.free) UserSchema.methods.isPremium = -> return true if @isInGodMode() return true if @isAdmin() return true if @hasSubscription() return false UserSchema.methods.isOnPremiumServer = -> return true if @get('country') in ['brazil'] return true if @get('country') in ['china'] and @isPremium() return false UserSchema.methods.isOnFreeOnlyServer = -> return true if @get('country') in ['china'] and not @isPremium() return false UserSchema.methods.level = -> xp = @get('points') or 0 a = 5 b = c = 100 if xp > 0 then Math.floor(a * Math.log((1 / b) * (xp + c))) + 1 else 1 UserSchema.methods.isEnrolled = -> coursePrepaid = @get('coursePrepaid') return false unless coursePrepaid return true unless coursePrepaid.endDate return coursePrepaid.endDate > new Date().toISOString() UserSchema.statics.saveActiveUser = (id, event, done=null) -> # TODO: Disabling this until we know why our app servers CPU grows out of control. return done?() id = mongoose.Types.ObjectId id if _.isString id @findById id, (err, user) -> if err? log.error err else user?.saveActiveUser event done?() UserSchema.methods.saveActiveUser = (event, done=null) -> # TODO: Disabling this until we know why our app servers CPU grows out of control. return done?() try return done?() if @isAdmin() userID = @get('_id') # Create if no active user entry for today today = new Date() minDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())) AnalyticsUsersActive.findOne({created: {$gte: minDate}, creator: mongoose.Types.ObjectId(userID)}).exec (err, activeUser) -> if err? log.error "saveActiveUser error retrieving active users: #{err}" else if not activeUser newActiveUser = new AnalyticsUsersActive() newActiveUser.set 'creator', userID newActiveUser.set 'event', event newActiveUser.save (err) -> log.error "Level session saveActiveUser error saving active user: #{err}" if err? done?() catch err log.error err done?() UserSchema.pre('save', (next) -> if _.isNaN(@get('purchased')?.gems) return next(new errors.InternalServerError('Attempting to save NaN to user')) Classroom = require './Classroom' if @isTeacher() and not @wasTeacher Classroom.update({members: @_id}, {$pull: {members: @_id}}, {multi: true}).exec (err, res) -> if email = @get('email') @set('emailLower', email.toLowerCase()) else @set('email', undefined) @set('emailLower', undefined) if name = @get('name') if not @allowEmailNames # for testing filter = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i # https://news.ycombinator.com/item?id=5763990 if filter.test(name) return next(new errors.UnprocessableEntity('Username may not be an email')) @set('nameLower', name.toLowerCase()) else @set('name', undefined) @set('nameLower', undefined) if _.isEmpty(@get('firstName')) @set('firstName', undefined) if _.isEmpty(@get('lastName')) @set('lastName', undefined) unless email or name or @get('anonymous') or @get('deleted') return next(new errors.UnprocessableEntity('User needs a username or email address')) pwd = @get('password') if @get('password') @set('passwordHash', User.hashPassword(pwd)) @set('password', undefined) next() ) UserSchema.post 'save', (doc) -> doc.newsSubsChanged = not _.isEqual(_.pick(doc.get('emails'), mail.NEWS_GROUPS), _.pick(doc.startingEmails, mail.NEWS_GROUPS)) UserSchema.statics.updateServiceSettings(doc) UserSchema.post 'init', (doc) -> doc.wasTeacher = doc.isTeacher() doc.startingEmails = _.cloneDeep(doc.get('emails')) if @get('coursePrepaidID') and not @get('coursePrepaid') Prepaid = require './Prepaid' @set('coursePrepaid', { _id: @get('coursePrepaidID') startDate: Prepaid.DEFAULT_START_DATE endDate: Prepaid.DEFAULT_END_DATE }) @set('coursePrepaidID', undefined) UserSchema.statics.hashPassword = (password) -> password = password.toLowerCase() shasum = crypto.createHash('sha512') shasum.update(salt + password) shasum.digest('hex') UserSchema.methods.verificationCode = (timestamp) -> { _id, email } = this.toObject() shasum = crypto.createHash('sha256') hash = shasum.update(timestamp + salt + _id + email).digest('hex') return "#{timestamp}:#{hash}" UserSchema.statics.privateProperties = [ 'permissions', 'email', 'mailChimp', 'firstName', 'lastName', 'gender', 'facebookID', 'gplusID', 'music', 'volume', 'aceConfig', 'employerAt', 'signedEmployerAgreement', 'emailSubscriptions', 'emails', 'activity', 'stripe', 'stripeCustomerID', 'chinaVersion', 'country', 'schoolName', 'ageRange', 'role', 'enrollmentRequestSent' ] UserSchema.statics.jsonSchema = jsonschema UserSchema.statics.editableProperties = [ 'name', 'photoURL', 'password', 'anonymous', 'wizardColor1', 'volume', 'firstName', 'lastName', 'gender', 'ageRange', 'facebookID', 'gplusID', 'emails', 'testGroupNumber', 'music', 'hourOfCode', 'hourOfCodeComplete', 'preferredLanguage', 'wizard', 'aceConfig', 'autocastDelay', 'lastLevel', 'jobProfile', 'savedEmployerFilterAlerts', 'heroConfig', 'iosIdentifierForVendor', 'siteref', 'referrer', 'schoolName', 'role', 'birthday', 'enrollmentRequestSent' ] UserSchema.statics.serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP'] UserSchema.statics.candidateProperties = [ 'jobProfile', 'jobProfileApproved', 'jobProfileNotes'] UserSchema.set('toObject', { transform: (doc, ret, options) -> req = options.req return ret unless req # TODO: Make deleting properties the default, but the consequences are far reaching publicOnly = options.publicOnly delete ret[prop] for prop in User.serverProperties includePrivates = not publicOnly and (req.user and (req.user.isAdmin() or req.user._id.equals(doc._id) or req.session.amActually is doc.id)) if options.includedPrivates excludedPrivates = _.reject User.privateProperties, (prop) -> prop in options.includedPrivates else excludedPrivates = User.privateProperties delete ret[prop] for prop in excludedPrivates unless includePrivates delete ret[prop] for prop in User.candidateProperties return ret }) UserSchema.statics.makeNew = (req) -> user = new User({anonymous: true}) if global.testing # allows tests some control over user id creation newID = _.pad((User.idCounter++).toString(16), 24, '0') user.set('_id', newID) user.set 'testGroupNumber', Math.floor(Math.random() * 256) # also in app/core/auth lang = languages.languageCodeFromAcceptedLanguages req.acceptedLanguages user.set 'preferredLanguage', lang if lang[...2] isnt 'en' user.set 'preferredLanguage', 'pt-BR' if not user.get('preferredLanguage') and /br\.codecombat\.com/.test(req.get('host')) user.set 'preferredLanguage', 'zh-HANS' if not user.get('preferredLanguage') and /cn\.codecombat\.com/.test(req.get('host')) user.set 'lastIP', (req.headers['x-forwarded-for'] or req.connection.remoteAddress)?.split(/,? /)[0] user.set 'country', req.country if req.country user UserSchema.plugin plugins.NamedPlugin module.exports = User = mongoose.model('User', UserSchema) User.idCounter = 0 AchievablePlugin = require '../plugins/achievements' UserSchema.plugin(AchievablePlugin)