mail = require '../commons/mail' 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" #TODO: Ask Nick and Scott to change their environment variables and change the deploy ones lockManager = require '../commons/LockManager' #TODO: Ask Nick about email unsubscriptions module.exports.setup = (app) -> app.all config.mail.mailchimpWebhook, handleMailchimpWebHook app.get '/mail/cron/ladder-update', handleLadderUpdate if lockManager setupScheduledEmails() setupScheduledEmails = -> testForLockManager() mailTasks = [ taskFunction: candidateUpdateProfileTask frequencyMs: 30 * 60 * 1000 #30 minutes , taskFunction: internalCandidateUpdateTask frequencyMs: 10 * 60 * 1000 #10 minutes , taskFunction: employerNewCandidatesAvailableTask frequencyMs: 30 * 60 * 1000 #30 minutes ] for mailTask in mailTasks setInterval mailTask.taskFunction, mailTask.frequencyMs testForLockManager = -> unless lockManager then throw "The system isn't configured to do distributed locking!" ### Candidate Update Reminder Task ### candidateUpdateProfileTask = -> mailTaskName = "candidateUpdateProfileTask" lockDurationMs = 2 * 60 * 1000 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) -> if err? then return log.error "Error getting a distributed lock for task #{mailTaskName}!" async.each timeRanges, emailTimeRange.bind({mailTaskName: mailTaskName}), (err) -> if err log.error "There was an error sending the candidate profile update reminder emails: #{err}" else log.info "Completed mail task #{mailTaskName}" lockManager.releaseLock mailTaskName, (err) -> if err? then return log.error "There was an error releasing the distributed lock for task #{mailTaskName}: #{err}" generateWeekOffset = (originalDate, numberOfWeeks) -> return (new Date(originalDate.getTime() - numberOfWeeks * 7 * 24 * 60 * 60 * 1000)).toISOString() emailTimeRange = (timeRange, emailTimeRangeCallback) -> waterfallContext = "timeRange": timeRange "mailTaskName": @mailTaskName async.waterfall [ findAllCandidatesWithinTimeRange.bind(waterfallContext) (unfilteredCandidates, cb) -> async.reject unfilteredCandidates, candidateFilter.bind(waterfallContext), cb.bind(null, null) (filteredCandidates, cb) -> async.each filteredCandidates, sendReminderEmailToCandidate.bind(waterfallContext), cb ], emailTimeRangeCallback findAllCandidatesWithinTimeRange = (cb) -> findParameters = "jobProfile.updated": $gt: @timeRange.start $lte: @timeRange.end "jobProfileApproved": true selection = "_id email jobProfile.name jobProfile.updated emails" #make sure to check for anyNotes too. User.find(findParameters).select(selection).lean().exec cb candidateFilter = (candidate, sentEmailFilterCallback) -> if candidate.emails?.anyNotes?.enabled is false or candidate.emails?.recruitNotes?.enabled is false log.info "Candidate #{candidate.jobProfile.name} opted out of emails, not sending to them." return sentEmailFilterCallback true findParameters = "user": candidate._id "mailTask": @mailTaskName "metadata.timeRangeName": @timeRange.name "metadata.updated": candidate.jobProfile.updated MailSent.find(findParameters).lean().exec (err, sentMail) -> if err? log.error "Error finding mail sent for task #{@mailTaskName} and user #{candidate._id}!" sentEmailFilterCallback true else 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) => if err? log.error "There was an error finding employers who signed up after #{candidate.jobProfile.updated}: #{err}" return sendEmailCallback err context = email_id: "tem_CtTLsKQufxrxoPMn7upKiL" recipient: address: candidate.email name: candidate.jobProfile.name email_data: new_company: employersAfterCount company_name: "CodeCombat" user_profile: "http://codecombat.com/account/profile/#{candidate._id}" 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 ### End Candidate Update Reminder Task ### ### Internal Candidate Update Reminder Email ### internalCandidateUpdateTask = -> mailTaskName = "internalCandidateUpdateTask" lockDurationMs = 2 * 60 * 1000 lockManager.setLock mailTaskName, lockDurationMs, (err) -> if err? then return log.error "Error getting a distributed lock for task #{mailTaskName}!" emailInternalCandidateUpdateReminder.apply {"mailTaskName":mailTaskName}, (err) -> if err log.error "There was an error sending the internal candidate update reminder.: #{err}" else log.info "Sent internal candidate update reminder email!" lockManager.releaseLock mailTaskName, (err) -> if err? then return log.error "There was an error releasing the distributed lock for task #{mailTaskName}: #{err}" emailInternalCandidateUpdateReminder = (internalCandidateUpdateReminderCallback) -> 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 ], internalCandidateUpdateReminderCallback 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? log.error "Error finding mail sent for task #{@mailTaskName} and user #{candidate._id}!" cb true else 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" email_data: new_candidate_profile: "https://codecombat.com/account/profile/#{candidate._id}" 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 ### End Internal Candidate Update Reminder Email ### ### Employer New Candidates Available Email ### employerNewCandidatesAvailableTask = -> mailTaskName = "employerNewCandidatesAvailableTask" lockDurationMs = 2 * 60 * 1000 lockManager.setLock mailTaskName, lockDurationMs, (err) -> if err? then return log.error "There was an error getting a task lock!" emailEmployerNewCandidatesAvailable.apply {"mailTaskName":mailTaskName}, (err) -> if err log.error "There was an error completing the new candidates available task: #{err}" else log.info "Completed the employer new candidates available task!" lockManager.releaseLock mailTaskName, (err) -> if err? then return log.error "There was an error releasing the distributed lock for task #{mailTaskName}: #{err}" emailEmployerNewCandidatesAvailable = (emailEmployerNewCandidatesAvailableCallback) -> currentTime = new Date() asyncContext = "currentTime": currentTime "mailTaskName": @mailTaskName async.waterfall [ findAllEmployers makeEmployerNamesEasilyAccessible (allEmployers, cb) -> async.reject allEmployers, employersEmailedDigestMoreThanWeekAgoFilter.bind(asyncContext), cb.bind(null,null) (employersToEmail, cb) -> async.each employersToEmail, sendEmployerNewCandidatesAvailableEmail.bind(asyncContext), cb ], emailEmployerNewCandidatesAvailableCallback findAllEmployers = (cb) -> findParameters = "employerAt": $exists: true permissions: "employer" selection = "_id email employerAt signedEmployerAgreement.data.firstName signedEmployerAgreement.data.lastName activity dateCreated emails" User.find(findParameters).select(selection).lean().exec cb makeEmployerNamesEasilyAccessible = (allEmployers, cb) -> 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) -> if employer.emails?.employerNotes?.enabled is false log.info "Employer #{employer.name}(#{employer.email}) opted out of emails, not sending to them." return sentEmailFilterCallback true 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? log.error "Error finding mail sent for task #{@mailTaskName} and employer #employer._id}!" cb true else cb Boolean(sentMail.length) sendEmployerNewCandidatesAvailableEmail = (employer, cb) -> lastLoginDate = employer.activity?.login?.last ? employer.dateCreated countParameters = "jobProfileApproved": 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 email_data: new_candidates: numberOfCandidatesSinceLogin employer_company_name: employer.employerAt company_name: "CodeCombat" if employer.name context.recipient.name = employer.name 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 ### 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 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