codecombat/server/routes/mail.coffee
2014-07-16 13:56:23 -07:00

494 lines
21 KiB
CoffeeScript

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