From d211bc7f861517a16d0d653f1be69b6e1d72ba92 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 27 Jan 2015 15:43:21 +0100 Subject: [PATCH 1/2] feat(email-notifications): implement email notifications for events --- public/js/app.js | 4 ++ src/controllers/auth.js | 6 +- src/controllers/challenges.js | 6 ++ src/controllers/groups.js | 31 +++++++++-- src/controllers/members.js | 18 ++++++ src/controllers/payments/index.js | 22 +++++++- src/controllers/user.js | 3 + src/models/user.js | 15 ++++- src/utils.js | 93 +++++++++++++++++++++---------- views/options/settings.jade | 61 ++++++++++++++++++++ views/shared/header/menu.jade | 2 + 11 files changed, 217 insertions(+), 44 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 3ee68b2a9a..742f31cfcc 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -213,6 +213,10 @@ window.habitrpg = angular.module('habitrpg', url: "/subscription", templateUrl: "partials/options.settings.subscription.html" }) + .state('options.settings.notifications', { + url: "/notifications", + templateUrl: "partials/options.settings.notifications.html" + }) var settings = JSON.parse(localStorage.getItem(STORAGE_SETTINGS_ID)); if (settings && settings.auth) { diff --git a/src/controllers/auth.js b/src/controllers/auth.js index 3a00eda7f0..a4c04a458a 100644 --- a/src/controllers/auth.js +++ b/src/controllers/auth.js @@ -103,7 +103,7 @@ api.registerUser = function(req, res, next) { } user.save(cb); - if(isProd) utils.txnEmail({name:username, email:email}, 'welcome'); + utils.txnEmail(user, 'welcome'); ga.event('register', 'Local').send() } ], function(err, saved) { @@ -173,9 +173,7 @@ api.loginSocial = function(req, res, next) { user = new User(user); user.save(cb); - if (isProd && prof.emails && prof.emails[0] && prof.emails[0].value) { - utils.txnEmail({name: prof.displayName || prof.username, email: prof.emails[0].value}, 'welcome'); - } + utils.txnEmail(user, 'welcome'); ga.event('register', network).send(); }] }, function(err, results){ diff --git a/src/controllers/challenges.js b/src/controllers/challenges.js index 71e1d82d33..b77fb00c0f 100644 --- a/src/controllers/challenges.js +++ b/src/controllers/challenges.js @@ -9,6 +9,7 @@ var Group = require('./../models/group').model; var Challenge = require('./../models/challenge').model; var logging = require('./../logging'); var csv = require('express-csv'); +var utils = require('../utils'); var api = module.exports; @@ -335,6 +336,11 @@ api.selectWinner = function(req, res, next) { winner.save(cb); }, function(saved, num, cb) { + if(saved.preferences.emailNotifications.wonChallenge !== false){ + utils.txnEmail(saved, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: chal.name} + ]); + } closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb); } ], function(err){ diff --git a/src/controllers/groups.js b/src/controllers/groups.js index 22268838ef..6fb71bdd42 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -301,13 +301,11 @@ api.flagChatMessage = function(req, res, next){ group.markModified('chat'); group.save(function(err,_saved){ if(err) return next(err); - if (isProd){ - var addressesToSendTo = JSON.parse(nconf.get('FLAG_REPORT_EMAIL')); if(Array.isArray(addressesToSendTo)){ addressesToSendTo = addressesToSendTo.map(function(email){ - return {email: email} + return {email: email, canSend: true} }); }else{ addressesToSendTo = {email: addressesToSendTo} @@ -332,7 +330,7 @@ api.flagChatMessage = function(req, res, next){ {name: "GROUP_ID", content: group._id}, {name: "GROUP_URL", content: group._id == 'habitrpg' ? (nconf.get('BASE_URL') + '/#/options/groups/tavern') : (group.type === 'guild' ? (nconf.get('BASE_URL')+ '/#/options/groups/guilds/' + group._id) : 'party')}, ]); - } + return res.send(204); }); }); @@ -556,6 +554,26 @@ api.invite = function(req, res, next) { ], function(err, results){ if (err) return next(err); + if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ + var emailVars = [ + {name: 'INVITER', content: utils.getUserInfo(res.locals.user, ['name']).name} + ]; + + if(group.type == 'guild'){ + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: nconf.get('BASE_URL') + '/#/options/groups/guilds/' + group._id} + ); + }else{ + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'} + ) + } + + utils.txnEmail(invite, ('invited-' + group.type == 'guild' ? 'guild' : 'party'), emailVars); + } + // Have to return whole group and its members for angular to show the invited user res.json(results[2]); group = uuid = null; @@ -629,7 +647,7 @@ questStart = function(req, res, next) { var group = res.locals.group; var force = req.query.force; -// if (group.quest.active) return res.json(400,{err:'Quest already began.'}); + // if (group.quest.active) return res.json(400,{err:'Quest already began.'}); // temporarily send error email, until we know more about this issue (then remove below, uncomment above). if (group.quest.active) return next('Quest already began.'); @@ -720,8 +738,9 @@ api.questAccept = function(req, res, next) { if (m == user._id) { group.quest.members[m] = true; group.quest.leader = user._id; - } else + } else { group.quest.members[m] = undefined; + } }); // Party member accepting the invitation diff --git a/src/controllers/members.js b/src/controllers/members.js index 73db176865..f0b465547f 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -5,6 +5,8 @@ var api = module.exports; var async = require('async'); var _ = require('lodash'); var shared = require('habitrpg-shared'); +var utils = require('../utils'); +var nconf = require('nconf'); var fetchMember = function(uuid, restrict){ return function(cb){ @@ -48,9 +50,11 @@ api.sendMessage = function(user, member, data){ } api.sendPrivateMessage = function(req, res, next){ + var fetchedMember; async.waterfall([ fetchMember(req.params.uuid), function(member, cb) { + fetchedMember = member; if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me || ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them || member.inbox.optOut) { // or if they've opted out of messaging @@ -64,6 +68,14 @@ api.sendPrivateMessage = function(req, res, next){ } ], function(err){ if (err) return sendErr(err, res, next); + + if(fetchedMember.preferences.emailNotifications.newPM !== false){ + utils.txnEmail(fetchedMember, 'new-pm', [ + {name: 'SENDER', content: utils.getUserInfo(res.locals.user, ['name']).name}, + {name: 'PMS_INBOX_URL', content: nconf.get('BASE_URL') + '/#/options/groups/inbox'} + ]); + } + res.send(200); }) } @@ -84,6 +96,12 @@ api.sendGift = function(req, res, next){ member.balance += amt; user.balance -= amt; api.sendMessage(user, member, req.body); + if(member.preferences.emailNotifications.giftedGems !== false){ + utils.txnEmail(member, 'gifted-gems', [ + {name: 'GIFTER', content: utils.getUserInfo(user, ['name']).name}, + {name: 'X_GEMS_GIFTED', content: amt} + ]); + } return async.parallel([ function (cb2) { member.save(cb2) }, function (cb2) { user.save(cb2) } diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index c2f83959de..5b9a5a5269 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -73,7 +73,15 @@ exports.createSubscription = function(data, cb) { utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send(); } data.user.purchased.txnCount++; - if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); + if (data.gift){ + members.sendMessage(data.user, data.gift.member, data.gift); + if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ + utils.txnEmail(member, 'gifted-subscription', [ + {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, + {name: 'X_MONTHS_SUBSCRIPTION', content: months} + ]); + } + } async.parallel([ function(cb2){data.user.save(cb2)}, function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} @@ -96,7 +104,7 @@ exports.cancelSubscription = function(data, cb) { p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated data.user.save(cb); - if(isProduction) utils.txnEmail(data.user, 'cancel-subscription'); + utils.txnEmail(data.user, 'cancel-subscription'); utils.ga.event('unsubscribe', data.paymentMethod).send(); } @@ -110,7 +118,15 @@ exports.buyGems = function(data, cb) { //TODO ga.transaction to reflect whether this is gift or self-purchase utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); } - if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); + if (data.gift){ + members.sendMessage(data.user, data.gift.member, data.gift); + if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ + utils.txnEmail(member, 'gifted-gems', [ + {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, + {name: 'X_GEMS_GIFTED', content: amt} + ]); + } + } async.parallel([ function(cb2){data.user.save(cb2)}, function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} diff --git a/src/controllers/user.js b/src/controllers/user.js index 43cc9ff8a2..8557dd22a8 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -445,6 +445,9 @@ api.inviteFriends = function(req, res, next) { {name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name}, {name: 'INVITEE', content: invite.name} ]; + + invite.canSend = true; + // TODO implement "users can only be invited once" utils.txnEmail(invite, 'invite-friend', variables); } diff --git a/src/models/user.js b/src/models/user.js index b61c7ec531..62862bfd75 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -297,7 +297,20 @@ var UserSchema = new Schema({ advancedCollapsed: {type: Boolean, 'default': false}, toolbarCollapsed: {type:Boolean, 'default':false}, background: String, - webhooks: {type: Schema.Types.Mixed, 'default': {}} + webhooks: {type: Schema.Types.Mixed, 'default': {}}, + // For this fields make sure to use strict comparison when searching for falsey values (=== false) + // As users who didn't login after these were introduced may have them undefined/null + emailNotifications: { + unsubscribeFromAll: {type: Boolean, 'default': false}, + newPM: {type: Boolean, 'default': true}, + wonChallenge: {type: Boolean, 'default': true}, + giftedGems: {type: Boolean, 'default': true}, + giftedSubscription: {type: Boolean, 'default': true}, + invitedParty: {type: Boolean, 'default': true}, + invitedGuild: {type: Boolean, 'default': true}, + //remindersToLogin: {type: Boolean, 'default': true}, + importantAnnouncements: {type: Boolean, 'default': true} + } }, profile: { blurb: String, diff --git a/src/utils.js b/src/utils.js index 613cde9bdd..55cab68ede 100644 --- a/src/utils.js +++ b/src/utils.js @@ -4,6 +4,9 @@ var crypto = require('crypto'); var path = require("path"); var request = require('request'); +// Set when utils.setupConfig is run +var isProd, baseUrl; + module.exports.ga = undefined; // set Google Analytics on nconf init module.exports.sendEmail = function(mailData) { @@ -22,48 +25,75 @@ module.exports.sendEmail = function(mailData) { }); } -function getMailingInfo(user) { - var email, name; - if(user.auth.local && user.auth.local.email){ - email = user.auth.local.email; - name = user.profile.name || user.auth.local.username; - }else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){ - email = user.auth.facebook.emails[0].value; - name = user.auth.facebook.displayName || user.auth.facebook.username; +function getUserInfo(user, fields) { + var info = {}; + + if(fields.indexOf('name') != -1){ + if(user.auth.local){ + info.name = user.profile.name || user.auth.local.username; + }else if(user.auth.facebook){ + info.name = user.auth.facebook.displayName || user.auth.facebook.username; + } } - return {email: email, name: name}; + + if(fields.indexOf('email') != -1){ + if(user.auth.local){ + info.email = user.auth.local.email; + }else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){ + info.email = user.auth.facebook.emails[0].value; + } + } + + if(fields.indexOf('canSend') != -1){ + info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true; + } + + return info; } +module.exports.getUserInfo = getUserInfo; + module.exports.txnEmail = function(mailingInfoArray, emailType, variables){ - var variables = [{name: 'BASE_URL', content: nconf.get('BASE_URL')}].concat(variables || []); var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; + var variables = [ + {name: 'BASE_URL', content: baseUrl}, + {name: 'EMAIL_SETTINGS_URL', content: baseUrl + '/#/options/settings/notifications'} + ].concat(variables || []); + // It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed mailingInfoArray = mailingInfoArray.map(function(mailingInfo){ - return mailingInfo._id ? getMailingInfo(mailingInfo) : mailingInfo; + return mailingInfo._id ? getUserInfo(mailingInfo, ['email', 'name', 'canSend']) : mailingInfo; }).filter(function(mailingInfo){ - return mailingInfo.email ? true : false; + return (mailingInfo.email && mailingInfo.canSend); }); - request({ - url: nconf.get('EMAIL_SERVER:url') + '/job', - method: 'POST', - auth: { - user: nconf.get('EMAIL_SERVER:authUser'), - pass: nconf.get('EMAIL_SERVER:authPassword') - }, - json: { - type: 'email', - data: { - emailType: emailType, - to: mailingInfoArray, - variables: variables + // When only one recipient send his info as variables + if(mailingInfoArray.length === 1 && mailingInfoArray[0].name){ + variables.push({name: 'RECIPIENT_NAME', content: mailingInfoArray[0].name}); + } + + if(isProd && mailingInfoArray.length > 0){ + request({ + url: nconf.get('EMAIL_SERVER:url') + '/job', + method: 'POST', + auth: { + user: nconf.get('EMAIL_SERVER:authUser'), + pass: nconf.get('EMAIL_SERVER:authPassword') }, - options: { - attemps: 5, - backoff: {delay: 10*60*1000, type: 'fixed'} + json: { + type: 'email', + data: { + emailType: emailType, + to: mailingInfoArray, + variables: variables + }, + options: { + attemps: 5, + backoff: {delay: 10*60*1000, type: 'fixed'} + } } - } - }); + }); + } } // Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/ @@ -93,6 +123,9 @@ module.exports.setupConfig = function(){ if (nconf.get('NODE_ENV') === 'production') require('newrelic'); + isProd = nconf.get('NODE_ENV') === 'production'; + baseUrl = nconf.get('BASE_URL'); + module.exports.ga = require('universal-analytics')(nconf.get('GA_ID')); }; diff --git a/views/options/settings.jade b/views/options/settings.jade index b6208ffc88..53c756ebcf 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -14,6 +14,8 @@ script(id='partials/options.settings.html', type="text/ng-template") =env.t('coupon') li(ng-class="{ active: $state.includes('options.settings.subscription') }") a(ui-sref='options.settings.subscription')=env.t('subscription') + li(ng-class="{ active: $state.includes('options.settings.notifications') }") + a(ui-sref='options.settings.notifications')=env.t('notifications') .tab-content .tab-pane.active @@ -84,6 +86,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') |  =env.t('subWarning3') + .personal-options.col-md-6 .panel.panel-default .panel-heading span Registration @@ -242,6 +245,64 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template') input.focusable(type='checkbox', checked) label +script(id='partials/options.settings.notifications.html', type="text/ng-template") + .container-fluid + .row + .personal-options.col-md-6 + .panel.panel-default + .panel-heading + =env.t('emailNotifications') + .panel-body + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.newPM', ng-change='set({"preferences.emailNotifications.newPM": user.preferences.emailNotifications.newPM ? true: false})') + span=env.t('newPM') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.wonChallenge', ng-change='set({"preferences.emailNotifications.wonChallenge": user.preferences.emailNotifications.wonChallenge ? true: false})') + span=env.t('wonChallenge') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedGems', ng-change='set({"preferences.emailNotifications.giftedGems": user.preferences.emailNotifications.giftedGems ? true: false})') + span=env.t('giftedGems') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.giftedSubscription', ng-change='set({"preferences.emailNotifications.giftedSubscription": user.preferences.emailNotifications.giftedSubscription ? true: false})') + span=env.t('giftedSubscription') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedParty', ng-change='set({"preferences.emailNotifications.invitedParty": user.preferences.emailNotifications.invitedParty ? true: false})') + span=env.t('invitedParty') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedGuild', ng-change='set({"preferences.emailNotifications.invitedGuild": user.preferences.emailNotifications.invitedGuild ? true: false})') + span=env.t('invitedGuild') + + //.checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.remindersToLogin', ng-change='set({"preferences.emailNotifications.remindersToLogin": user.preferences.emailNotifications.remindersToLogin ? true: false})') + span=env.t('remindersToLogin') + + .checkbox + label + input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.importantAnnouncements', ng-change='set({"preferences.emailNotifications.importantAnnouncements": user.preferences.emailNotifications.importantAnnouncements ? true: false})') + span=env.t('importantAnnouncements') + + hr + + .checkbox + label + input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll', ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})') + span=env.t('unsubscribeAllEmails') + + small=env.t('unsubscribeAllEmailsText') + script(id='partials/options.settings.subscription.html',type='text/ng-template') //-h2=env.t('individualSub') .container-fluid(ng-init='_subscription={key:"basic_earned"}') diff --git a/views/shared/header/menu.jade b/views/shared/header/menu.jade index 549af9c4e5..a08920b3af 100644 --- a/views/shared/header/menu.jade +++ b/views/shared/header/menu.jade @@ -242,6 +242,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') a(ui-sref='options.settings.coupon') Coupon li a(ui-sref='options.settings.subscription')=env.t('subscription') + li + a(ui-sref='options.settings.notifications')=env.t('notifications') ul.toolbar-submenu(ng-click='expandMenu(null)') li a(href="http://habitrpg.wikia.com/wiki/FAQ", target='_blank')=env.t('FAQ') From b0a2f1fcaad45101c966cc6a42cf25fa5f32d601 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 2 Feb 2015 16:50:14 +0100 Subject: [PATCH 2/2] check for subscription preferences when sending party invitations --- src/controllers/user.js | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/controllers/user.js b/src/controllers/user.js index 8557dd22a8..5f70d13780 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -437,19 +437,34 @@ api.cast = function(req, res, next) { api.inviteFriends = function(req, res, next) { Group.findOne({type:'party', members:{'$in': [res.locals.user._id]}}).select('_id name').exec(function(err,party){ if (err) return next(err); - var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name})); + _.each(req.body.emails, function(invite){ if (invite.email) { - var variables = [ - {name: 'LINK', content: link}, - {name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name}, - {name: 'INVITEE', content: invite.name} - ]; - invite.canSend = true; + User.findOne({$or: [ + {'auth.local.email': invite.email}, + {'auth.facebook.emails.value': invite.email} + ]}).select({_id: true, 'preferences.emailNotifications': true}) + .exec(function(err, userToContact){ + if(err) return next(err); + + var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name})); + + var variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: req.body.inviter || utils.getUserInfo(res.locals.user, ['name']).name} + ]; + + invite.canSend = true; + + // We check for unsubscribeFromAll here because don't pass through utils.getUserInfo + if(userToContact.preferences.emailNotifications.invitedParty !== false && + userToContact.preferences.emailNotifications.unsubscribeFromAll !== true){ + // TODO implement "users can only be invited once" + utils.txnEmail(invite, 'invite-friend', variables); + } + }); - // TODO implement "users can only be invited once" - utils.txnEmail(invite, 'invite-friend', variables); } }); res.send(200);