mail = require '../commons/mail' MailTask = require '../mail/tasks/MailTask' MailSent = require '../mail/sent/MailSent' User = require '../users/User' async = require 'async' errors = require '../commons/errors' config = require '../../server_config' LevelSession = require '../levels/sessions/LevelSession' Level = require '../levels/Level' log = require 'winston' sendwithus = require '../sendwithus' if config.isProduction or config.redis.host isnt "localhost" lockManager = require '../commons/LockManager' module.exports.setup = (app) -> app.all config.mail.mailchimpWebhook, handleMailchimpWebHook app.get '/mail/cron/ladder-update', handleLadderUpdate app.post '/mail/task', createMailTask if lockManager setupScheduledEmails() setupScheduledEmails = -> testForLockManager() mailTaskMap = "test_mail_task": employerNewCandidatesAvailableTask MailTask.find({}).lean().exec (err, mailTasks) -> if err? then throw "Failed to schedule mailTasks! #{err}" for mailTask in mailTasks setInterval mailTaskMap[mailTask.url], mailTask.frequency*2 testForLockManager = -> unless lockManager then throw "The system isn't configured to do distributed locking!" ### Candidate Update Reminder Task ### emailTimeRange = (timeRange, finalCallback) -> waterfallContext = "timeRange": timeRange "mailTaskName": @mailTaskName async.waterfall [ findAllCandidatesWithinTimeRange.bind(waterfallContext) (unfilteredCandidates, cb) -> #now filter the candidates to see if they are eligible async.reject unfilteredCandidates, candidateFilter.bind(waterfallContext), (filtered) -> cb null, filtered (filteredCandidates, cb) -> #Now send emails to the eligible candidates and record. async.each filteredCandidates, sendReminderEmailToCandidate.bind(waterfallContext), cb ], finalCallback findAllCandidatesWithinTimeRange = (cb) -> findParameters = "jobProfile.updated": $gt: @timeRange.start $lte: @timeRange.end "jobProfileApproved": true selection = "_id email jobProfile.name jobProfile.updated" User.find(findParameters).select(selection).lean().exec cb candidateFilter = (candidate, sentEmailFilterCallback) -> findParameters = "user": candidate._id "mailTask": @mailTaskName "metadata.timeRangeName": @timeRange.name "metadata.updated": candidate.jobProfile.updated MailSent.find(findParameters).lean().exec (err, sentMail) -> if err? then return errors.serverError("Error fetching sent mail in email task") sentEmailFilterCallback Boolean(sentMail.length) findEmployersSignedUpAfterDate = (dateObject, cb) -> countParameters = $or: [{"dateCreated": {$gte: dateObject}},{"signedEmployerAgreement":{$gte: dateObject}}] employerAt: {$exists: true} permissions: "employer" User.count countParameters, cb sendReminderEmailToCandidate = (candidate, sendEmailCallback) -> findEmployersSignedUpAfterDate new Date(candidate.jobProfile.updated), (err, employersAfterCount) => context = email_id: "tem_CtTLsKQufxrxoPMn7upKiL" recipient: address: candidate.email name: candidate.jobProfile.name email_data: profile_updated: candidate.jobProfile.updated #format nicely new_company: employersAfterCount log.info "Sending #{@timeRange.name} update reminder to #{context.recipient.name}(#{context.recipient.address})" newSentMail = mailTask: @mailTaskName user: candidate._id metadata: timeRangeName: @timeRange.name updated: candidate.jobProfile.updated MailSent.create newSentMail, (err) -> if err? then return sendEmailCallback err sendwithus.api.send context, (err, result) -> log.error "Error sending candidate update reminder email: #{err} with result #{result}" if err sendEmailCallback null generateWeekOffset = (originalDate, numberOfWeeks) -> return (new Date(originalDate.getTime() - numberOfWeeks * 7 * 24 * 60 * 60 * 1000)).toISOString() candidateUpdateProfileTask = -> mailTaskName = "candidateUpdateProfileTask" lockDurationMs = 6000 currentDate = new Date() timeRanges = [] for weekPair in [[4, 2,'two weeks'], [8, 4, 'four weeks'], [8, 52, 'eight weeks']] timeRanges.push start: generateWeekOffset currentDate, weekPair[0] end: generateWeekOffset currentDate, weekPair[1] name: weekPair[2] lockManager.setLock mailTaskName, lockDurationMs, (err, lockResult) -> if err? then return log.error "Error getting a task lock!" async.each timeRanges, emailTimeRange.bind(mailTaskName: mailTaskName), (err) -> if err then log.error JSON.stringify err else log.info "Sent candidate update reminders!" lockManager.releaseLock mailTaskName, (err, result) -> if err? then return log.error err ### End Candidate Update Reminder Task ### ### Internal Candidate Update Reminder Email ### emailInternalCandidateUpdateReminder = (cb) -> currentTime = new Date() beginningOfUTCDay = new Date() beginningOfUTCDay.setUTCHours(0,0,0,0) asyncContext = "beginningOfUTCDay": beginningOfUTCDay "currentTime": currentTime "mailTaskName": @mailTaskName async.waterfall [ findNonApprovedCandidatesWhoUpdatedJobProfileToday.bind(asyncContext) (unfilteredCandidates, cb) -> async.reject unfilteredCandidates, candidatesUpdatedTodayFilter.bind(asyncContext), cb.bind(null,null) (filteredCandidates, cb) -> async.each filteredCandidates, sendInternalCandidateUpdateReminder.bind(asyncContext), cb ], cb findNonApprovedCandidatesWhoUpdatedJobProfileToday = (cb) -> findParameters = "jobProfile.updated": $lte: @currentTime.toISOString() gt: @beginningOfUTCDay.toISOString() "jobProfileApproved": false User.find(findParameters).select("_id jobProfile.name jobProfile.updated").lean().exec cb candidatesUpdatedTodayFilter = (candidate, cb) -> findParameters = "user": candidate._id "mailTask": @mailTaskName "metadata.beginningOfUTCDay": @beginningOfUTCDay MailSent.find(findParameters).lean().exec (err, sentMail) -> if err? then return errors.serverError("Error fetching sent mail in #{@mailTaskName}") cb Boolean(sentMail.length) sendInternalCandidateUpdateReminder = (candidate, cb) -> context = email_id: "tem_Ac7nhgKqatTHBCgDgjF5pE" recipient: address: "team@codecombat.com" #Change to whatever email address is necessary name: "The CodeCombat Team" log.info "Sending candidate updated reminder for #{candidate.jobProfile.name}" newSentMail = mailTask: @mailTaskName user: candidate._id metadata: beginningOfUTCDay: @beginningOfUTCDay MailSent.create newSentMail, (err) -> if err? then return cb err sendwithus.api.send context, (err, result) -> log.error "Error sending interal candidate update email: #{err} with result #{result}" if err cb null internalCandidateUpdateTask = -> mailTaskName = "internalCandidateUpdateTask" lockDurationMs = 6000 lockManager.setLock mailTaskName, lockDurationMs, (err, lockResult) -> if err? then return log.error "Error getting a task lock!" emailInternalCandidateUpdateReminder.apply {"mailTaskName":mailTaskName}, (err) -> if err? then log.error "There was an error sending the internal candidate update reminder." lockManager.releaseLock mailTaskName, (err, result) -> if err? then return log.error err ### End Internal Candidate Update Reminder Email ### ### Employer New Candidates Available Email ### emailEmployerNewCandidatesAvailableEmail = (cb) -> currentTime = new Date() asyncContext = "currentTime": currentTime "mailTaskName": @mailTaskName async.waterfall [ findAllEmployers makeEmployerNamesEasilyAccessible (allEmployers, cb) -> console.log "Found #{allEmployers.length} employers to email about new candidates available" async.reject allEmployers, employersEmailedDigestMoreThanWeekAgoFilter.bind(asyncContext), cb.bind(null,null) (employersToEmail, cb) -> async.each employersToEmail, sendEmployerNewCandidatesAvailableEmail.bind(asyncContext), cb ], cb findAllEmployers = (cb) -> findParameters = "employerAt": $exists: true permissions: "employer" selection = "_id email employerAt signedEmployerAgreement.data.firstName signedEmployerAgreement.data.lastName activity dateCreated" User.find(findParameters).select(selection).lean().exec cb makeEmployerNamesEasilyAccessible = (allEmployers, cb) -> #Make names easily accessible for employer, index in allEmployers if employer.signedEmployerAgreement?.data?.firstName employer.name = employer.signedEmployerAgreement.data.firstName + " " + employer.signedEmployerAgreement.data.lastName delete employer.signedEmployerAgreement allEmployers[index] = employer cb null, allEmployers employersEmailedDigestMoreThanWeekAgoFilter = (employer, cb) -> findParameters = "user": employer._id "mailTask": @mailTaskName "sent": $gt: new Date(@currentTime.getTime() - 7 * 24 * 60 * 60 * 1000) MailSent.find(findParameters).lean().exec (err, sentMail) -> if err? then return errors.serverError("Error fetching sent mail in #{@mailTaskName}") cb Boolean(sentMail.length) sendEmployerNewCandidatesAvailableEmail = (employer, cb) -> lastLoginDate = employer.activity?.login?.last ? employer.dateCreated countParameters = "jobProfileApproved": true "jobProfile": $exists: true $or: [ jobProfileApprovedDate: $gt: lastLoginDate.toISOString() , jobProfileApprovedDate: $exists: false "jobProfile.updated": $gt: lastLoginDate.toISOString() ] User.count countParameters, (err, numberOfCandidatesSinceLogin) => if err? then return cb err context = email_id: "tem_CCcHKr95Nvu5bT7c7iHCtm" recipient: address: employer.email name: employer.name email_data: new_candidates: numberOfCandidatesSinceLogin employer_company_name: employer.employerAt company_name: "CodeCombat" log.info "Sending available candidates update reminder to #{context.recipient.name}(#{context.recipient.address})" newSentMail = mailTask: @mailTaskName user: employer._id MailSent.create newSentMail, (err) -> if err? then return cb err sendwithus.api.send context, (err, result) -> log.error "Error sending employer candidates available email: #{err} with result #{result}" if err cb null employerNewCandidatesAvailableTask = -> #initialize featuredDate to job profile updated mailTaskName = "employerNewCandidatesAvailableTask" lockDurationMs = 6000 lockManager.setLock mailTaskName, lockDurationMs, (err, lockResult) -> if err? then return log.error "There was an error getting a task lock!" emailEmployerNewCandidatesAvailableEmail.apply {"mailTaskName":mailTaskName}, (err) -> if err? then return log.error "There was an error performing the #{mailTaskName} email task." lockManager.releaseLock mailTaskName, (err, result) -> if err? then return log.error err ### End Employer New Candidates Available Email ### ### New Recruit Leaderboard Email ### ### newRecruitLeaderboardEmailTask = -> # tem_kMQFCKX3v4DNAQDsMAsPJC #maxRank and maxRankTime should be recorded if isSimulating is false mailTaskName = "newRecruitLeaderboardEmailTask" lockDurationMs = 6000 lockManager.setLock mailTaskName, lockDurationMs, (err, lockResult) -> ### ### End New Recruit Leaderboard Email ### ### Employer Matching Candidate Notification Email ### ### employerMatchingCandidateNotificationTask = -> # tem_mYsepTfWQ265noKfZJcbBH #save email filters in their own collection mailTaskName = "employerMatchingCandidateNotificationTask" lockDurationMs = 6000 lockManager.setLock mailTaskName, lockDurationMs, (err, lockResult) -> ### ### End Employer Matching Candidate Notification Email ### ### Ladder Update Email ### DEBUGGING = false LADDER_PREGAME_INTERVAL = 2 * 3600 * 1000 # Send emails two hours before players last submitted. getTimeFromDaysAgo = (now, daysAgo) -> t = now - 86400 * 1000 * daysAgo - LADDER_PREGAME_INTERVAL isRequestFromDesignatedCronHandler = (req, res) -> requestIP = req.headers['x-forwarded-for']?.replace(' ', '').split(',')[0] if requestIP isnt config.mail.cronHandlerPublicIP and requestIP isnt config.mail.cronHandlerPrivateIP console.log "RECEIVED REQUEST FROM IP #{requestIP}(headers indicate #{req.headers['x-forwarded-for']}" console.log 'UNAUTHORIZED ATTEMPT TO SEND TRANSACTIONAL LADDER EMAIL THROUGH CRON MAIL HANDLER' res.send('You aren\'t authorized to perform that action. Only the specified Cron handler may perform that action.') res.end() return false return true createMailTask = (req, res) -> #unless req.user?.isAdmin() then return errors.forbidden(res) unless req.body.url and req.body.frequency then return errors.badInput(res) console.log "Creating mail task with url #{req.body.url} and frequency #{req.body.frequency}" newMailTask = new MailTask {} newMailTask.set("url",req.body.url) newMailTask.set("frequency",req.body.frequency) newMailTask.save (err) -> if err? then return errors.serverError(res, err) res.send("Created mail task!") res.end() handleLadderUpdate = (req, res) -> log.info('Going to see about sending ladder update emails.') requestIsFromDesignatedCronHandler = isRequestFromDesignatedCronHandler req, res return unless requestIsFromDesignatedCronHandler or DEBUGGING res.send('Great work, Captain Cron! I can take it from here.') res.end() # TODO: somehow fetch the histograms emailDays = [1, 2, 4, 7, 14, 30] now = new Date() for daysAgo in emailDays # Get every session that was submitted in a 5-minute window after the time. startTime = getTimeFromDaysAgo now, daysAgo endTime = startTime + 5 * 60 * 1000 if DEBUGGING endTime = startTime + 15 * 60 * 1000 # Debugging: make sure there's something to send findParameters = {submitted: true, submitDate: {$gt: new Date(startTime), $lte: new Date(endTime)}} # TODO: think about putting screenshots in the email selectString = 'creator team levelName levelID totalScore matches submitted submitDate scoreHistory' query = LevelSession.find(findParameters) .select(selectString) .lean() do (daysAgo) -> query.exec (err, results) -> if err log.error "Couldn't fetch ladder updates for #{findParameters}\nError: #{err}" return errors.serverError res, "Ladder update email query failed: #{JSON.stringify(err)}" log.info "Found #{results.length} ladder sessions to email updates about for #{daysAgo} day(s) ago." sendLadderUpdateEmail result, now, daysAgo for result in results sendLadderUpdateEmail = (session, now, daysAgo) -> User.findOne({_id: session.creator}).select('name email firstName lastName emailSubscriptions emails preferredLanguage').exec (err, user) -> if err log.error "Couldn't find user for #{session.creator} from session #{session._id}" return allowNotes = user.isEmailSubscriptionEnabled 'anyNotes' unless user.get('email') and allowNotes and not session.unsubscribed log.info "Not sending email to #{user.get('email')} #{user.get('name')} because they only want emails about #{user.get('emailSubscriptions')}, #{user.get('emails')} - session unsubscribed: #{session.unsubscribed}" return unless session.levelName log.info "Not sending email to #{user.get('email')} #{user.get('name')} because the session had no levelName in it." return name = if user.get('firstName') and user.get('lastName') then "#{user.get('firstName')}" else user.get('name') name = 'Wizard' if not name or name is 'Anoner' # Fetch the most recent defeat and victory, if there are any. # (We could look at strongest/weakest, but we'd have to fetch everyone, or denormalize more.) matches = _.filter session.matches, (match) -> match.date >= getTimeFromDaysAgo now, daysAgo defeats = _.filter matches, (match) -> match.metrics.rank is 1 and match.opponents[0].metrics.rank is 0 victories = _.filter matches, (match) -> match.metrics.rank is 0 and match.opponents[0].metrics.rank is 1 defeat = _.last defeats victory = _.last victories sendEmail = (defeatContext, victoryContext) -> # TODO: do something with the preferredLanguage? context = email_id: sendwithus.templates.ladder_update_email recipient: address: if DEBUGGING then 'nick@codecombat.com' else user.get('email') name: name email_data: name: name days_ago: daysAgo wins: victories.length losses: defeats.length total_score: Math.round(session.totalScore * 100) team: session.team team_name: session.team[0].toUpperCase() + session.team.substr(1) level_name: session.levelName session_id: session._id ladder_url: "http://codecombat.com/play/ladder/#{session.levelID}#my-matches" score_history_graph_url: getScoreHistoryGraphURL session, daysAgo defeat: defeatContext victory: victoryContext log.info "Sending ladder update email to #{context.recipient.address} with #{context.email_data.wins} wins and #{context.email_data.losses} losses since #{daysAgo} day(s) ago." sendwithus.api.send context, (err, result) -> log.error "Error sending ladder update email: #{err} with result #{result}" if err urlForMatch = (match) -> "http://codecombat.com/play/level/#{session.levelID}?team=#{session.team}&session=#{session._id}&opponent=#{match.opponents[0].sessionID}" onFetchedDefeatedOpponent = (err, defeatedOpponent) -> if err log.error "Couldn't find defeateded opponent: #{err}" defeatedOpponent = null victoryContext = {opponent_name: defeatedOpponent?.name ? 'Anoner', url: urlForMatch(victory)} if victory onFetchedVictoriousOpponent = (err, victoriousOpponent) -> if err log.error "Couldn't find victorious opponent: #{err}" victoriousOpponent = null defeatContext = {opponent_name: victoriousOpponent?.name ? 'Anoner', url: urlForMatch(defeat)} if defeat sendEmail defeatContext, victoryContext if defeat User.findOne({_id: defeat.opponents[0].userID}).select('name').lean().exec onFetchedVictoriousOpponent else onFetchedVictoriousOpponent null, null if victory User.findOne({_id: victory.opponents[0].userID}).select('name').lean().exec onFetchedDefeatedOpponent else onFetchedDefeatedOpponent null, null getScoreHistoryGraphURL = (session, daysAgo) -> # Totally duplicated in My Matches tab for now until we figure out what we're doing. since = new Date() - 86400 * 1000 * daysAgo scoreHistory = (s for s in session.scoreHistory ? [] when s[0] >= since) return '' unless scoreHistory.length > 1 scoreHistory = _.last scoreHistory, 100 # Chart URL needs to be under 2048 characters for GET times = (s[0] for s in scoreHistory) times = ((100 * (t - times[0]) / (times[times.length - 1] - times[0])).toFixed(1) for t in times) scores = (s[1] for s in scoreHistory) lowest = _.min scores #.concat([0]) highest = _.max scores #.concat(50) scores = (Math.round(100 * (s - lowest) / (highest - lowest)) for s in scores) currentScore = Math.round scoreHistory[scoreHistory.length - 1][1] * 100 minScore = Math.round(100 * lowest) maxScore = Math.round(100 * highest) chartData = times.join(',') + '|' + scores.join(',') "https://chart.googleapis.com/chart?chs=600x75&cht=lxy&chtt=Score%3A+#{currentScore}&chts=222222,12,r&chf=a,s,000000FF&chls=2&chd=t:#{chartData}&chxt=y&chxr=0,#{minScore},#{maxScore}" ### End Ladder Update Email ### handleMailchimpWebHook = (req, res) -> post = req.body unless post.type in ['unsubscribe', 'profile'] res.send 'Bad post type' return res.end() unless post.data.email res.send 'No email provided' return res.end() query = {'mailChimp.leid': post.data.web_id} User.findOne query, (err, user) -> return errors.serverError(res) if err if not user return errors.notFound(res) handleProfileUpdate(user, post) if post.type is 'profile' handleUnsubscribe(user) if post.type is 'unsubscribe' user.updatedMailChimp = true # so as not to echo back to mailchimp user.save (err) -> return errors.serverError(res) if err res.end('Success') module.exports.handleProfileUpdate = handleProfileUpdate = (user, post) -> mailchimpSubs = post.data.merges.INTERESTS.split(', ') for [mailchimpEmailGroup, emailGroup] in _.zip(mail.MAILCHIMP_GROUPS, mail.NEWS_GROUPS) user.setEmailSubscription emailGroup, mailchimpEmailGroup in mailchimpSubs fname = post.data.merges.FNAME user.set('firstName', fname) if fname lname = post.data.merges.LNAME user.set('lastName', lname) if lname user.set 'mailChimp.email', post.data.email user.set 'mailChimp.euid', post.data.id module.exports.handleUnsubscribe = handleUnsubscribe = (user) -> user.set 'emailSubscriptions', [] for emailGroup in mail.NEWS_GROUPS user.setEmailSubscription emailGroup, false