diff --git a/common/locales/en/quests.json b/common/locales/en/quests.json index 3f7be31868..e0546fcf8a 100644 --- a/common/locales/en/quests.json +++ b/common/locales/en/quests.json @@ -6,6 +6,8 @@ "questSend": "Clicking \"Invite\" will send an invitation to your party members. When all members have accepted or denied, the quest begins. See status under Options > Social > Party.", "inviteParty": "Invite Party", "questInvitation": "Quest Invitation: ", + "questInvitationTitle": "Quest Invitation", + "questInvitationInfo": "Invitation for the Quest <%= quest %>", "askLater": "Ask Later", "buyQuest": "Buy Quest", "accepted": "Accepted", diff --git a/common/locales/en/settings.json b/common/locales/en/settings.json index eacd8e0ef8..a1eb6ce215 100644 --- a/common/locales/en/settings.json +++ b/common/locales/en/settings.json @@ -93,6 +93,7 @@ "wonChallenge": "You Won a Challenge", "newPM": "Received Private Message", "giftedGems": "Gifted Gems", + "giftedGemsInfo": "<%= amount %> Gems - by <%= name %>", "giftedSubscription": "Gifted Subscription", "invitedParty": "Invited To Party", "invitedGuild": "Invited To Guild", diff --git a/common/script/index.coffee b/common/script/index.coffee index f251ad7db5..29833b0fc2 100644 --- a/common/script/index.coffee +++ b/common/script/index.coffee @@ -653,6 +653,19 @@ api.wrap = (user, main=true) -> user.markModified? 'preferences.webhooks' cb? null, user.preferences.webhooks + # ------ + # Push Notifications + # ------ + addPushDevice: (req, cb) -> + user.pushDevices = [] unless user.pushDevices + pd = user.pushDevices + item = {regId:req.body.regId, type:req.body.type}; + i = _.findIndex pd, {regId: item.regId} + + pd.push(item) unless i != -1 + + cb? null, user.pushDevices + # ------ # Inbox # ------ diff --git a/config.json.example b/config.json.example index 72f1a8c762..fd72ea4ce9 100644 --- a/config.json.example +++ b/config.json.example @@ -51,5 +51,12 @@ "token": "token", "username": "username", "password": "password" + }, + "PUSH_CONFIGS": { + "GCM_SERVER_API_KEY": "", + "APN_PEM_FILES": { + "KEY": "key.pem", + "CERT": "cert.pem" + } } } diff --git a/package.json b/package.json index 15340a7b24..04655f1101 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,11 @@ "grunt-nodemon": "~0.3.0", "grunt-spritesmith": "~3.5.0", "icalendar": "git://github.com/lefnire/node-icalendar#master", + "image-size": "~0.3.2", "in-app-purchase": "^0.2.0", "jade": "~1.7.0", "js2xmlparser": "~0.1.2", "lodash": "~2.4.1", - "image-size": "~0.3.2", "loggly": "~1.0.8", "method-override": "~2.2.0", "moment": "~2.8.3", @@ -51,6 +51,7 @@ "paypal-ipn": "2.1.0", "paypal-rest-sdk": "^1.2.1", "pretty-data": "git://github.com/vkiryukhin/pretty-data#master", + "push-notify": "^1.1.1", "qs": "^2.3.2", "request": "~2.44.0", "s3-upload-stream": "^1.0.6", @@ -102,9 +103,11 @@ "mongoskin": "~0.6.1", "phantomjssmith": "~0.5.4", "protractor": "~2.0.0", + "rewire": "^2.3.3", "rimraf": "^2.2.8", "shelljs": "^0.4.0", "sinon": "^1.12.2", + "sinon-chai": "^2.7.0", "superagent": "~0.15.7", "superagent-defaults": "~0.1.5", "vinyl-source-stream": "^1.0.0", diff --git a/test/api/api-helper.coffee b/test/api/api-helper.coffee index 31a774007d..0c3ae7b580 100644 --- a/test/api/api-helper.coffee +++ b/test/api/api-helper.coffee @@ -12,7 +12,8 @@ global.shared = require("../../common") global.User = require("../../website/src/models/user").model global.chai = require("chai") -global.expect = require("chai").expect +chai.use(require("sinon-chai")) +global.expect = chai.expect ############################## # Nconf config diff --git a/test/api/pushNotifications.coffee b/test/api/pushNotifications.coffee new file mode 100644 index 0000000000..9b43ca08c7 --- /dev/null +++ b/test/api/pushNotifications.coffee @@ -0,0 +1,306 @@ +'use strict' + +app = require("../../website/src/server") +rewire = require('rewire') +sinon = require('sinon') + +describe "Push-Notifications", -> + before (done) -> + registerNewUser(done, true) + + describe "POST /user/pushDevice", -> + it "Registers a DeviceID", (done) -> + request.post(baseURL + "/user/pushDevice").send( + { regId: "123123", type: "android"} + ).end (res) -> + expectCode res, 200 + + User.findOne + _id: global.user._id + , (err, _user) -> + expect(_user.pushDevices.length).to.equal 1 + expect(_user.pushDevices[0].regId).to.equal "123123" + + done() + + describe "Events that send push notifications", -> + pushSpy = { sendNotify: sinon.spy() } + + afterEach (done) -> + pushSpy.sendNotify.reset() + done() + + context "Challenges", -> + challenges = rewire("../../website/src/controllers/challenges") + challenges.__set__('pushNotify', pushSpy) + challengeMock = { + findById: (arg, cb) -> + cb(null, {leader: user._id, name: 'challenge-name'}) + } + userMock = { + findById: (arg, cb) -> + cb(null, user) + } + + challenges.__set__('Challenge', challengeMock) + challenges.__set__('User', userMock) + challenges.__set__('closeChal', -> true) + + beforeEach (done) -> + registerNewUser -> + user.preferences.emailNotifications.wonChallenge = false + user.save = (cb) -> cb(null, user) + done() + , true + + it "sends a push notification when you win a challenge", (done) -> + req = { + params: { cid: 'challenge-id' } + query: {uid: 'user-id'} + } + res = { + locals: { user: user } + } + challenges.selectWinner req, res + + setTimeout -> # Allow selectWinner to finish + expect(pushSpy.sendNotify).to.have.been.calledOnce + expect(pushSpy.sendNotify).to.have.been.calledWith( + user, + 'You Won a Challenge', + 'challenge-name' + ) + done() + , 100 + + context "Groups", -> + + recipient = null + + groups = rewire("../../website/src/controllers/groups") + groups.__set__('questStart', -> true) + groups.__set__('pushNotify', pushSpy) + + before (done) -> + registerNewUser (err,_user)-> + recipient = _user + recipient.invitations.guilds = [] + recipient.save = (cb) -> cb(null, recipient) + recipient.preferences.emailNotifications.invitedGuild = false + recipient.preferences.emailNotifications.invitedParty = false + recipient.preferences.emailNotifications.invitedQuest = false + userMock = { + findById: (arg, cb) -> + cb(null, recipient) + find: (arg, arg2, cb) -> + cb(null, [recipient]) + } + groups.__set__('User', userMock) + done() + + , false + + it "sends a push notification when invited to a guild", (done) -> + group = { _id: 'guild-id', name: 'guild-name', type: 'guild', members: [user._id], invites: [] } + group.save = (cb) -> cb(null, group) + req = { + body: { uuids: [recipient._id] } + } + res = { + locals: { group: group, user: user } + json: -> return true + } + + groups.invite req, res + + setTimeout -> # Allow invite to finish + expect(pushSpy.sendNotify).to.have.been.calledOnce + expect(pushSpy.sendNotify).to.have.been.calledWith( + recipient, + 'Invited To Guild', + group.name + ) + done() + , 100 + + it "sends a push notification when invited to a party", (done) -> + group = { _id: 'party-id', name: 'party-name', type: 'party', members: [user._id], invites: [] } + group.save = (cb) -> cb(null, group) + req = { + body: { uuids: [recipient._id] } + } + res = { + locals: { group: group, user: user } + json: -> return true + } + + groups.invite req, res + + setTimeout -> # Allow invite to finish + expect(pushSpy.sendNotify).to.have.been.calledOnce + expect(pushSpy.sendNotify).to.have.been.calledWith( + recipient, + 'Invited To Party', + group.name + ) + done() + , 100 + + it "sends a push notification when invited to a quest", (done) -> + group = { _id: 'party-id', name: 'party-name', type: 'party', members: [user._id, recipient._id], invites: [], quest: {}} + user.items.quests.hedgehog = 5 + group.save = (cb) -> cb(null, group) + req = { + body: { uuids: [recipient._id] } + query: { key: 'hedgehog' } + } + res = { + locals: { group: group, user: user } + json: -> return true + } + + groups.questAccept req, res + + setTimeout -> # Allow questAccept to finish + expect(pushSpy.sendNotify).to.have.been.calledOnce + expect(pushSpy.sendNotify).to.have.been.calledWith( + recipient, + 'Quest Invitation', + 'Invitation for the Quest The Hedgebeast' + ) + done() + , 100 + + describe "Gifts", -> + + recipient = null + + before (done) -> + registerNewUser (err, _user) -> + recipient = _user + recipient.preferences.emailNotifications.giftedGems = false + user.balance = 4 + user.save = -> return true + recipient.save = -> return true + done() + , false + + context "sending gems from balance", -> + members = rewire("../../website/src/controllers/members") + members.sendMessage = -> true + + members.__set__('pushNotify', pushSpy) + members.__set__ 'fetchMember', (id) -> + return (cb) -> cb(null, recipient) + + it "sends a push notification", (done) -> + req = { + params: { uuid: "uuid" }, + body: { + type: 'gems', + gems: { amount: 1 } + } + } + res = { locals: { user: user } } + + members.sendGift req, res + + setTimeout -> # Allow sendGift to finish + expect(pushSpy.sendNotify).to.have.been.calledOnce + expect(pushSpy.sendNotify).to.have.been.calledWith( + recipient, + 'Gifted Gems', + '1 Gems - by ' + user.profile.name + ) + done() + , 100 + + describe "Purchases", -> + + payments = rewire("../../website/src/controllers/payments") + + payments.__set__('pushNotify', pushSpy) + membersMock = { sendMessage: -> true } + payments.__set__('members', membersMock) + + context "buying gems as a purchased gift", -> + + it "sends a push notification", (done) -> + data = { + user: user, + gift: { + member: recipient, + gems: { amount: 1 } + } + } + + payments.buyGems data + + setTimeout -> # Allow buyGems to finish + expect(pushSpy.sendNotify).to.have.been.calledOnce + expect(pushSpy.sendNotify).to.have.been.calledWith( + recipient, + 'Gifted Gems', + '1 Gems - by ' + user.profile.name + ) + + done() + , 100 + + it "does not send a push notification if buying gems for self", (done) -> + data = { + user: user, + gift: { + member: user + gems: { amount: 1 } + } + } + + payments.buyGems data + + setTimeout -> # Allow buyGems to finish + expect(pushSpy.sendNotify).to.not.have.been.called + + done() + , 100 + + context "sending a subscription as a purchased gift", -> + + it "sends a push notification", (done) -> + data = { + user: user, + gift: { + member: recipient + subscription: { key: 'basic_6mo' } + } + } + + payments.createSubscription data + + setTimeout -> # Allow createSubscription to finish + expect(pushSpy.sendNotify).to.have.been.calledOnce + expect(pushSpy.sendNotify).to.have.been.calledWith( + recipient, + 'Gifted Subscription', + '6 months - by ' + user.profile.name + ) + + done() + , 100 + + it "does not send a push notification if buying subscription for self", (done) -> + data = { + user: user, + gift: { + member: user + subscription: { key: 'basic_6mo' } + } + } + + payments.createSubscription data + + setTimeout -> # Allow buyGems to finish + expect(pushSpy.sendNotify).to.not.have.been.called + + done() + , 100 diff --git a/website/src/controllers/challenges.js b/website/src/controllers/challenges.js index a522746b8f..6b2c59b809 100644 --- a/website/src/controllers/challenges.js +++ b/website/src/controllers/challenges.js @@ -11,7 +11,7 @@ var logging = require('./../logging'); var csv = require('express-csv'); var utils = require('../utils'); var api = module.exports; - +var pushNotify = require('./pushNotifications'); /* ------------------------------------------------------------------------ @@ -347,6 +347,9 @@ api.selectWinner = function(req, res, next) { {name: 'CHALLENGE_NAME', content: chal.name} ]); } + + pushNotify.sendNotify(saved, shared.i18n.t('wonChallenge'), chal.name); + closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb); } ], function(err){ diff --git a/website/src/controllers/groups.js b/website/src/controllers/groups.js index 3fd71919f7..b9d2643c61 100644 --- a/website/src/controllers/groups.js +++ b/website/src/controllers/groups.js @@ -15,6 +15,7 @@ var Challenge = require('./../models/challenge').model; var EmailUnsubscription = require('./../models/emailUnsubscription').model; var isProd = nconf.get('NODE_ENV') === 'production'; var api = module.exports; +var pushNotify = require('./pushNotifications'); /* ------------------------------------------------------------------------ @@ -578,9 +579,13 @@ var inviteByUUIDs = function(uuids, group, req, res, next){ function sendInvite (){ if(group.type === 'guild'){ invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); + + pushNotify.sendNotify(invite, shared.i18n.t('invitedGuild'), group.name); }else{ //req.body.type in 'guild', 'party' invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; + + pushNotify.sendNotify(invite, shared.i18n.t('invitedParty'), group.name); } group.invites.push(invite._id); @@ -908,6 +913,10 @@ api.questAccept = function(req, res, next) { group.quest.leader = user._id; } else { group.quest.members[m] = undefined; + + User.findById(m, function(err,groupMember){ + pushNotify.sendNotify(groupMember, shared.i18n.t('questInvitationTitle'), shared.i18n.t('questInvitationInfo', { quest: quest.text() })); + }); } }); diff --git a/website/src/controllers/members.js b/website/src/controllers/members.js index 8001d29db7..c7fe341d3d 100644 --- a/website/src/controllers/members.js +++ b/website/src/controllers/members.js @@ -7,6 +7,7 @@ var _ = require('lodash'); var shared = require('../../../common'); var utils = require('../utils'); var nconf = require('nconf'); +var pushNotify = require('./pushNotifications'); var fetchMember = function(uuid, restrict){ return function(cb){ @@ -96,12 +97,18 @@ api.sendGift = function(req, res, next){ member.balance += amt; user.balance -= amt; api.sendMessage(user, member, req.body); + + var byUsername = utils.getUserInfo(user, ['name']).name; + if(member.preferences.emailNotifications.giftedGems !== false){ utils.txnEmail(member, 'gifted-gems', [ - {name: 'GIFTER', content: utils.getUserInfo(user, ['name']).name}, + {name: 'GIFTER', content: byUsername}, {name: 'X_GEMS_GIFTED', content: req.body.gems.amount} ]); } + + pushNotify.sendNotify(member, shared.i18n.t('giftedGems'), shared.i18n.t('giftedGemsInfo', { amount: req.body.gems.amount, name: byUsername })); + return async.parallel([ function (cb2) { member.save(cb2) }, function (cb2) { user.save(cb2) } diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js index f117a48bc5..31d691a7fe 100644 --- a/website/src/controllers/payments/index.js +++ b/website/src/controllers/payments/index.js @@ -12,6 +12,7 @@ var async = require('async'); var iap = require('./iap'); var mongoose= require('mongoose'); var cc = require('coupon-code'); +var pushNotify = require('./../pushNotifications'); function revealMysteryItems(user) { _.each(shared.content.gear.flat, function(item) { @@ -75,12 +76,19 @@ exports.createSubscription = function(data, cb) { data.user.purchased.txnCount++; if (data.gift){ members.sendMessage(data.user, data.gift.member, data.gift); + + var byUserName = utils.getUserInfo(data.user, ['name']).name; + if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ utils.txnEmail(data.gift.member, 'gifted-subscription', [ - {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, + {name: 'GIFTER', content: byUserName}, {name: 'X_MONTHS_SUBSCRIPTION', content: months} ]); - } + } + + if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), months + " months - by "+ byUserName); + } } async.parallel([ function(cb2){data.user.save(cb2)}, @@ -119,13 +127,20 @@ exports.buyGems = function(data, cb) { utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); } if (data.gift){ + var byUsername = utils.getUserInfo(data.user, ['name']).name; + var gemAmount = data.gift.gems.amount || 20; + members.sendMessage(data.user, data.gift.member, data.gift); if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ utils.txnEmail(data.gift.member, 'gifted-gems', [ - {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, - {name: 'X_GEMS_GIFTED', content: data.gift.gems.amount || 20} + {name: 'GIFTER', content: byUsername}, + {name: 'X_GEMS_GIFTED', content: gemAmount} ]); } + + if (data.gift.member._id != data.user._id) { // Only send push notifications if sending to a user other than yourself + pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), gemAmount + ' Gems - by '+byUsername); + } } async.parallel([ function(cb2){data.user.save(cb2)}, @@ -153,4 +168,4 @@ exports.paypalCheckoutSuccess = paypal.executePayment; exports.paypalIPN = paypal.ipn; exports.iapAndroidVerify = iap.androidVerify; -exports.iapIosVerify = iap.iosVerify; \ No newline at end of file +exports.iapIosVerify = iap.iosVerify; diff --git a/website/src/controllers/pushNotifications.js b/website/src/controllers/pushNotifications.js new file mode 100644 index 0000000000..98ec0b5d82 --- /dev/null +++ b/website/src/controllers/pushNotifications.js @@ -0,0 +1,52 @@ +var api = module.exports; +var _ = require('lodash'); +var nconf = require('nconf'); + +var pushNotify = require('push-notify'); + +var gcmApiKey = nconf.get("PUSH_CONFIGS:GCM_SERVER_API_KEY"); + +var gcm = gcmApiKey ? pushNotify.gcm({ + apiKey: gcmApiKey, + retries: 3 +}) : undefined; + +if(gcm){ + gcm.on('transmitted', function (result, message, registrationId) { + console.info("transmitted", result, message, registrationId); + }); + + gcm.on('transmissionError', function (error, message, registrationId) { + console.info("transmissionError", error, message, registrationId); + }); + gcm.on('updated', function (result, registrationId) { + console.info("updated", result, registrationId); + }); +} + +api.sendNotify = function(user, title, msg, timeToLive){ + timeToLive = timeToLive || 15; + + _.forEach(user.pushDevices, function(pushDevice){ + switch(pushDevice.type){ + case "android": + if(gcm){ + gcm.send({ + registrationId: pushDevice.regId, + //collapseKey: 'COLLAPSE_KEY', + delayWhileIdle: true, + timeToLive: timeToLive, + data: { + title: title, + message: msg + } + }); + } + + break; + + case "ios": + break; + } + }); +}; diff --git a/website/src/models/user.js b/website/src/models/user.js index 61f44e1724..60385abfbb 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -393,7 +393,12 @@ var UserSchema = new Schema({ todos: {type:[TaskSchemas.TodoSchema]}, rewards: {type:[TaskSchemas.RewardSchema]}, - extra: Schema.Types.Mixed + extra: Schema.Types.Mixed, + + pushDevices: {type: [{ + regId: {type: String}, + type: {type: String} + }],'default': []} }, { strict: true, diff --git a/website/src/routes/apiv2.coffee b/website/src/routes/apiv2.coffee index f30b03cb53..9694c0f37f 100644 --- a/website/src/routes/apiv2.coffee +++ b/website/src/routes/apiv2.coffee @@ -396,6 +396,16 @@ module.exports = (swagger, v2) -> ] action: user.deleteWebhook + # Push Notifications + "/user/pushDevice": + spec: + method: 'POST' + description: 'Add a new push devices registration ID' + parameters: [ + body '','New push registration { regId: "123123", type: "android"}','object' + ] + action: user.addPushDevice + # --------------------------------- # Groups # ---------------------------------