diff --git a/migrations/duplicatedTasksFindAndRemove.js b/migrations/duplicatedTasksFindAndRemove.js
new file mode 100644
index 0000000000..a6288efc4a
--- /dev/null
+++ b/migrations/duplicatedTasksFindAndRemove.js
@@ -0,0 +1,90 @@
+/*
+ * IMPORTANT:
+ *
+ * DO NOT TRUST THIS SCRIPT YET
+ *
+ *
+ * This has been written by Alys to identify and remove duplicated tasks
+ * i.e., tasks that have the same `id` value as another task.
+ * However it could almost certainly be improved (the aggregation step HAS
+ * to be easier that this!) and Alys is still working on it. Improvements
+ * welcome.
+ *
+ * If you use it, do ALL of the following things:
+ *
+ * - configuration, as described below
+ * - make a full backup of the user's data
+ * - be aware of how to restore the user's data from that backup
+ * - test the script first on a local copy of the database
+ * - dump the user's data to a text file before running the script so that
+ * it can later be compared to a dump made afterwards
+ * - run the script once first with both of the db.users.update() commands
+ * commented-out and check that the printed task IDs are correct
+ * - run the script with all code enabled
+ * - dump the user's data to a text file after running the script
+ * - diff the two dumps to ensure that only the correct tasks were removed
+ *
+ *
+ * When two tasks exist with the same ID, only one of those tasks will be
+ * removed (whichever copy the script finds first).
+ * If three tasks exist with the same ID, you'll probably need to run this
+ * script twice.
+ *
+ */
+
+
+// CONFIGURATION:
+// - Change the uuid below to be the user's uuid.
+// - Change ALL instances of "todos" to "habits"/"dailys"/"rewards" as
+// needed. Do not miss any of them!
+
+
+var uuid='30fb2640-7121-4968-ace5-f385e60ea6c5';
+
+db.users.aggregate([
+ {$match: {
+ '_id': uuid
+ }},
+ {$project: {
+ '_id':0, 'todos':1
+ }},
+ {$unwind: '$todos'},
+ {$group: {
+ _id: { taskid: '$todos.id' },
+ count: { $sum: 1 }
+ }},
+ {$match: {
+ count: { $gt: 1 }
+ }},
+ {$project: {
+ '_id.taskid':1,
+ }},
+ {$group: {
+ _id: { taskid: '$todos.id' },
+ troublesomeIds: { $addToSet: "$_id.taskid" },
+ }},
+ {$project: {
+ '_id':0,
+ troublesomeIds:1,
+ }},
+]).forEach(
+ function(data) {
+ // print( "\n" ); printjson(data);
+ data.troublesomeIds.forEach( function(taskid) {
+ print('non-unique task: ' + taskid);
+ db.users.update({
+ '_id': uuid,
+ 'todos': { $elemMatch: { id: taskid } }
+ },{
+ $set: { "todos.$.id" : 'de666' }
+ });
+ });
+ }
+);
+
+db.users.update(
+ {'_id': uuid},
+ {$pull: { todos: { id: 'de666' } } },
+ {multi: false }
+);
+
diff --git a/migrations/freeMonth.js b/migrations/freeMonth.js
index 36f320746c..dba8d256ee 100644
--- a/migrations/freeMonth.js
+++ b/migrations/freeMonth.js
@@ -8,4 +8,25 @@ db.users.update(
'purchased.plan.planId':'basic_earned',
'purchased.plan.dateTerminated': moment().add('month',1).toDate()
}}
-)
\ No newline at end of file
+)
+
+// db.users.update(
+// {_id:''},
+// {$set:{'purchased.plan':{
+// planId: 'basic_3mo',
+// paymentMethod: 'Paypal',
+// customerId: 'Gift',
+// dateCreated: new Date(),
+// dateTerminated: moment().add('month',3).toDate()
+// dateUpdated: new Date(),
+// extraMonths: 0,
+// gemsBought: 0,
+// mysteryItems: [],
+// consecutive: {
+// count: 0,
+// offset: 3,
+// gemCapExtra: 15,
+// trinkets: 1
+// }
+// }}}
+// )
\ No newline at end of file
diff --git a/src/controllers/groups.js b/src/controllers/groups.js
new file mode 100644
index 0000000000..7d874e0d50
--- /dev/null
+++ b/src/controllers/groups.js
@@ -0,0 +1,866 @@
+// @see ../routes for routing
+
+function clone(a) {
+ return JSON.parse(JSON.stringify(a));
+}
+
+var _ = require('lodash');
+var nconf = require('nconf');
+var async = require('async');
+var utils = require('./../utils');
+var shared = require('habitrpg-shared');
+var User = require('./../models/user').model;
+var Group = require('./../models/group').model;
+var Challenge = require('./../models/challenge').model;
+var isProd = nconf.get('NODE_ENV') === 'production';
+var api = module.exports;
+
+/*
+ ------------------------------------------------------------------------
+ Groups
+ ------------------------------------------------------------------------
+*/
+
+var partyFields = api.partyFields = 'profile preferences stats achievements party backer contributor auth.timestamps items';
+var nameFields = 'profile.name';
+var challengeFields = '_id name';
+var guildPopulate = {path: 'members', select: nameFields, options: {limit: 15} };
+/**
+ * For parties, we want a lot of member details so we can show their avatars in the header. For guilds, we want very
+ * limited fields - and only a sampling of the members, beacuse they can be in the thousands
+ * @param type: 'party' or otherwise
+ * @param q: the Mongoose query we're building up
+ * @param additionalFields: if we want to populate some additional field not fetched normally
+ * pass it as a string, parties only
+ */
+var populateQuery = function(type, q, additionalFields){
+ if (type == 'party')
+ q.populate('members', partyFields + (additionalFields ? (' ' + additionalFields) : ''));
+ else
+ q.populate(guildPopulate);
+ q.populate('invites', nameFields);
+ q.populate({
+ path: 'challenges',
+ match: (type=='habitrpg') ? {_id:{$ne:'95533e05-1ff9-4e46-970b-d77219f199e9'}} : undefined, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug
+ select: challengeFields,
+ options: {sort: {official: -1, timestamp: -1}}
+ });
+ return q;
+}
+
+/**
+ * Fetch groups list. This no longer returns party or tavern, as those can be requested indivdually
+ * as /groups/party or /groups/tavern
+ */
+api.list = function(req, res, next) {
+ var user = res.locals.user;
+ var groupFields = 'name description memberCount balance leader';
+ var sort = '-memberCount';
+ var type = req.query.type || 'party,guilds,public,tavern';
+
+ async.parallel({
+
+ // unecessary given our ui-router setup
+ party: function(cb){
+ if (!~type.indexOf('party')) return cb(null, {});
+ Group.findOne({type: 'party', members: {'$in': [user._id]}})
+ .select(groupFields).exec(function(err, party){
+ if (err) return cb(err);
+ cb(null, (party === null ? [] : [party])); // return as an array for consistent ngResource use
+ });
+ },
+
+ guilds: function(cb) {
+ if (!~type.indexOf('guilds')) return cb(null, []);
+ Group.find({members: {'$in': [user._id]}, type:'guild'})
+ .select(groupFields).sort(sort).exec(cb);
+ },
+
+ 'public': function(cb) {
+ if (!~type.indexOf('public')) return cb(null, []);
+ Group.find({privacy: 'public'})
+ .select(groupFields + ' members')
+ .sort(sort)
+ .exec(function(err, groups){
+ if (err) return cb(err);
+ _.each(groups, function(g){
+ // To save some client-side performance, don't send down the full members arr, just send down temp var _isMember
+ if (~g.members.indexOf(user._id)) g._isMember = true;
+ g.members = undefined;
+ });
+ cb(null, groups);
+ });
+ },
+
+ // unecessary given our ui-router setup
+ tavern: function(cb) {
+ if (!~type.indexOf('tavern')) return cb(null, {});
+ Group.findById('habitrpg').select(groupFields).exec(function(err, tavern){
+ if (err) return cb(err);
+ cb(null, [tavern]); // return as an array for consistent ngResource use
+ });
+ }
+
+ }, function(err, results){
+ if (err) return next(err);
+ // ngResource expects everything as arrays. We used to send it down as a structured object: {public:[], party:{}, guilds:[], tavern:{}}
+ // but unfortunately ngResource top-level attrs are considered the ngModels in the list, so we had to do weird stuff and multiple
+ // requests to get it to work properly. Instead, we're not depending on the client to do filtering / organization, and we're
+ // just sending down a merged array. Revisit
+ var arr = _.reduce(results, function(m,v){
+ if (_.isEmpty(v)) return m;
+ return m.concat(_.isArray(v) ? v : [v]);
+ }, [])
+ res.json(arr);
+
+ user = groupFields = sort = type = null;
+ })
+};
+
+/**
+ * Get group
+ * TODO: implement requesting fields ?fields=chat,members
+ */
+api.get = function(req, res, next) {
+ var user = res.locals.user;
+ var gid = req.params.gid;
+
+ var q = (gid == 'party')
+ ? Group.findOne({type: 'party', members: {'$in': [user._id]}})
+ : Group.findOne({$or:[
+ {_id:gid, privacy:'public'},
+ {_id:gid, privacy:'private', members: {$in:[user._id]}} // if the group is private, only return if they have access
+ ]});
+ populateQuery(gid, q);
+ q.exec(function(err, group){
+ if (err) return next(err);
+ if (!group && gid!=='party') return res.json(404,{err: "Group not found or you don't have access."});
+ res.json(group);
+ gid = null;
+ });
+};
+
+
+api.create = function(req, res, next) {
+ var group = new Group(req.body);
+ var user = res.locals.user;
+ group.members = [user._id];
+ group.leader = user._id;
+
+ if(group.type === 'guild'){
+ if(user.balance < 1) return res.json(401, {err: 'Not enough gems!'});
+
+ group.balance = 1;
+ user.balance--;
+
+ async.waterfall([
+ function(cb){user.save(cb)},
+ function(saved,ct,cb){group.save(cb)},
+ function(saved,ct,cb){saved.populate('members',nameFields,cb)}
+ ],function(err,saved){
+ if (err) return next(err);
+ res.json(saved);
+ group = user = null;
+ });
+
+ }else{
+ async.waterfall([
+ function(cb){
+ Group.findOne({type:'party',members:{$in:[user._id]}},cb);
+ },
+ function(found, cb){
+ if (found) return cb('Already in a party, try refreshing.');
+ group.save(cb);
+ },
+ function(saved, count, cb){
+ saved.populate('members', nameFields, cb);
+ }
+ ], function(err, populated){
+ if (err == 'Already in a party, try refreshing.') return res.json(400,{err:err});
+ if (err) return next(err);
+ return res.json(populated);
+ group = user = null;
+ })
+ }
+}
+
+api.update = function(req, res, next) {
+ var group = res.locals.group;
+ var user = res.locals.user;
+
+ if(group.leader !== user._id)
+ return res.json(401, {err: "Only the group leader can update the group!"});
+
+ 'name description logo logo leaderMessage leader leaderOnly'.split(' ').forEach(function(attr){
+ group[attr] = req.body[attr];
+ });
+
+ group.save(function(err, saved){
+ if (err) return next(err);
+ res.send(204);
+ });
+}
+
+api.attachGroup = function(req, res, next) {
+ var gid = req.params.gid;
+ var q = (gid == 'party') ? Group.findOne({type: 'party', members: {'$in': [res.locals.user._id]}}) : Group.findById(gid);
+ q.exec(function(err, group){
+ if(err) return next(err);
+ if(!group) return res.json(404, {err: "Group not found"});
+ res.locals.group = group;
+ next();
+ })
+}
+
+api.getChat = function(req, res, next) {
+ // TODO: This code is duplicated from api.get - pull it out into a function to remove duplication.
+ var user = res.locals.user;
+ var gid = req.params.gid;
+ var q = (gid == 'party')
+ ? Group.findOne({type: 'party', members: {$in:[user._id]}})
+ : Group.findOne({$or:[
+ {_id:gid, privacy:'public'},
+ {_id:gid, privacy:'private', members: {$in:[user._id]}}
+ ]});
+ populateQuery(gid, q);
+ q.exec(function(err, group){
+ if (err) return next(err);
+ if (!group && gid!=='party') return res.json(404,{err: "Group not found or you don't have access."});
+ res.json(res.locals.group.chat);
+ gid = null;
+ });
+};
+
+/**
+ * TODO make this it's own ngResource so we don't have to send down group data with each chat post
+ */
+api.postChat = function(req, res, next) {
+ var user = res.locals.user
+ var group = res.locals.group;
+ if (group.type!='party' && user.flags.chatRevoked) return res.json(401,{err:'Your chat privileges have been revoked.'});
+ var lastClientMsg = req.query.previousMsg;
+ var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false;
+
+ group.sendChat(req.query.message, user); // FIXME this should be body, but ngResource is funky
+
+ if (group.type === 'party') {
+ user.party.lastMessageSeen = group.chat[0].id;
+ user.save();
+ }
+
+ group.save(function(err, saved){
+ if (err) return next(err);
+ return chatUpdated ? res.json({chat: group.chat}) : res.json({message: saved.chat[0]});
+ group = chatUpdated = null;
+ });
+}
+
+api.deleteChatMessage = function(req, res, next){
+ var user = res.locals.user
+ var group = res.locals.group;
+ var message = _.find(group.chat, {id: req.params.messageId});
+
+ if(!message) return res.json(404, {err: "Message not found!"});
+
+ if(user._id !== message.uuid && !(user.backer && user.contributor.admin))
+ return res.json(401, {err: "Not authorized to delete this message!"})
+
+ var lastClientMsg = req.query.previousMsg;
+ var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false;
+
+ Group.update({_id:group._id}, {$pull:{chat:{id: req.params.messageId}}}, function(err){
+ if(err) return next(err);
+ chatUpdated ? res.json({chat: group.chat}) : res.send(204);
+ group = chatUpdated = null;
+ });
+}
+
+api.flagChatMessage = function(req, res, next){
+ var user = res.locals.user
+ var group = res.locals.group;
+ var message = _.find(group.chat, {id: req.params.mid});
+
+ if(!message) return res.json(404, {err: "Message not found!"});
+ if(message.uuid == user._id) return res.json(401, {err: "Can't report your own message."});
+
+ User.findOne({_id: message.uuid}, {auth: 1}, function(err, author){
+ if(err) return next(err);
+
+ // Log user ids that have flagged the message
+ if(!message.flags) message.flags = {};
+ if(message.flags[user._id] && !user.contributor.admin) return res.json(401, {err: "You have already reported this message"});
+ message.flags[user._id] = true;
+
+ // Log total number of flags (publicly viewable)
+ if(!message.flagCount) message.flagCount = 0;
+ if(user.contributor.admin){
+ // Arbitraty amount, higher than 2
+ message.flagCount = 5;
+ } else {
+ message.flagCount++
+ }
+
+ group.markModified('chat');
+ group.save(function(err,_saved){
+ if(err) return next(err);
+ var addressesToSendTo = JSON.parse(nconf.get('FLAG_REPORT_EMAIL'));
+
+ if(Array.isArray(addressesToSendTo)){
+ addressesToSendTo = addressesToSendTo.map(function(email){
+ return {email: email, canSend: true}
+ });
+ }else{
+ addressesToSendTo = {email: addressesToSendTo}
+ }
+
+ utils.txnEmail(addressesToSendTo, 'flag-report-to-mods', [
+ {name: "MESSAGE_TIME", content: (new Date(message.timestamp)).toString()},
+ {name: "MESSAGE_TEXT", content: message.text},
+
+ {name: "REPORTER_USERNAME", content: user.profile.name},
+ {name: "REPORTER_UUID", content: user._id},
+ {name: "REPORTER_EMAIL", content: user.auth.local ? user.auth.local.email : ((user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) ? user.auth.facebook.emails[0].value : null)},
+ {name: "REPORTER_MODAL_URL", content: "https://habitrpg.com/static/front/#?memberId=" + user._id},
+
+ {name: "AUTHOR_USERNAME", content: message.user},
+ {name: "AUTHOR_UUID", content: message.uuid},
+ {name: "AUTHOR_EMAIL", content: author.auth.local ? author.auth.local.email : ((author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) ? author.auth.facebook.emails[0].value : null)},
+ {name: "AUTHOR_MODAL_URL", content: "https://habitrpg.com/static/front/#?memberId=" + message.uuid},
+
+ {name: "GROUP_NAME", content: group.name},
+ {name: "GROUP_TYPE", content: group.type},
+ {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);
+ });
+ });
+
+}
+
+api.clearFlagCount = function(req, res, next){
+ var user = res.locals.user
+ var group = res.locals.group;
+ var message = _.find(group.chat, {id: req.params.mid});
+
+ if(!message) return res.json(404, {err: "Message not found!"});
+
+ if(user.contributor.admin){
+ message.flagCount = 0;
+
+ group.markModified('chat');
+ group.save(function(err,_saved){
+ if(err) return next(err);
+ return res.send(204);
+ });
+ }else{
+ return res.json(401, {err: "Only an admin can clear the flag count!"})
+ }
+
+}
+
+api.seenMessage = function(req,res,next){
+ // Skip the auth step, we want this to be fast. If !found with uuid/token, then it just doesn't save
+ // Check for req.params.gid to exist
+ if(req.params.gid){
+ var update = {$unset:{}};
+ update['$unset']['newMessages.'+req.params.gid] = '';
+ User.update({_id:req.headers['x-api-user'], apiToken:req.headers['x-api-key']},update).exec();
+ }
+ res.send(200);
+}
+
+api.likeChatMessage = function(req, res, next) {
+ var user = res.locals.user;
+ var group = res.locals.group;
+ var message = _.find(group.chat, {id: req.params.mid});
+ if (!message) return res.json(404, {err: "Message not found!"});
+ if (message.uuid == user._id) return res.json(401, {err: "Can't like your own message. Don't be that person."});
+ if (!message.likes) message.likes = {};
+ if (message.likes[user._id]) {
+ delete message.likes[user._id];
+ } else {
+ message.likes[user._id] = true;
+ }
+ group.markModified('chat');
+ group.save(function(err,_saved){
+ if (err) return next(err);
+ return res.send(_saved.chat);
+ })
+}
+
+api.join = function(req, res, next) {
+ var user = res.locals.user,
+ group = res.locals.group;
+
+ if (group.type == 'party' && group._id == (user.invitations && user.invitations.party && user.invitations.party.id)) {
+ User.update({_id:user.invitations.party.inviter}, {$inc:{'items.quests.basilist':1}}).exec(); // Reward inviter
+ user.invitations.party = undefined; // Clear invite
+ user.save();
+ // invite new user to pending quest
+ if (group.quest.key && !group.quest.active) {
+ group.quest.members[user._id] = undefined;
+ group.markModified('quest.members');
+ }
+ }
+ else if (group.type == 'guild' && user.invitations && user.invitations.guilds) {
+ var i = _.findIndex(user.invitations.guilds, {id:group._id});
+ if (~i) user.invitations.guilds.splice(i,1);
+ user.save();
+ }
+
+ if (!_.contains(group.members, user._id)){
+ group.members.push(user._id);
+ group.invites.splice(_.indexOf(group.invites, user._id), 1);
+ }
+
+ async.series([
+ function(cb){
+ group.save(cb);
+ },
+ function(cb){
+ populateQuery(group.type, Group.findById(group._id)).exec(cb);
+ }
+ ], function(err, results){
+ if (err) return next(err);
+
+ // Return the group? Or not?
+ res.json(results[1]);
+ group = null;
+ });
+}
+
+api.leave = function(req, res, next) {
+ var user = res.locals.user,
+ group = res.locals.group;
+ // When removing the user from challenges, should we keep the tasks?
+ var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all';
+ async.parallel([
+ // Remove active quest from user if they're leaving the party
+ function(cb){
+ if (group.type != 'party') return cb(null,{},1);
+ user.party.quest = Group.cleanQuestProgress();
+ user.save(cb);
+ },
+ // Remove user from group challenges
+ function(cb){
+ async.waterfall([
+ // Find relevant challenges
+ function(cb2) {
+ Challenge.find({
+ _id: {$in: user.challenges}, // Challenges I am in
+ group: group._id // that belong to the group I am leaving
+ }, cb2);
+ },
+ // Update each challenge
+ function(challenges, cb2) {
+ Challenge.update(
+ {_id:{$in: _.pluck(challenges, '_id')}},
+ {$pull:{members:user._id}},
+ {multi: true},
+ function(err) {
+ cb2(err, challenges); // pass `challenges` above to cb
+ }
+ );
+ },
+ // Unlink the challenge tasks from user
+ function(challenges, cb2) {
+ async.waterfall(challenges.map(function(chal) {
+ return function(cb3) {
+ var i = user.challenges.indexOf(chal._id)
+ if (~i) user.challenges.splice(i,1);
+ user.unlink({cid:chal._id, keep:keep}, cb3);
+ }
+ }), cb2);
+ }
+ ], cb);
+ },
+ // Update the group
+ function(cb){
+ var update = {$pull:{members:user._id}};
+ if (group.type == 'party' && group.quest.key){
+ update['$unset'] = {};
+ update['$unset']['quest.members.' + user._id] = 1;
+ }
+ // FIXME do we want to remove the group `if group.members.length == 0` ? (well, 1 since the update hasn't gone through yet)
+ if (group.members.length > 1) {
+ var seniorMember = _.find(group.members, function (m) {return m != user._id});
+ // If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
+ var leader = group.leader;
+ if (leader == user._id || !~group.members.indexOf(leader)) {
+ update['$set'] = update['$set'] || {};
+ update['$set'].leader = seniorMember;
+ }
+ leader = group.quest && group.quest.leader;
+ if (leader && (leader == user._id || !~group.members.indexOf(leader))) {
+ update['$set'] = update['$set'] || {};
+ update['$set']['quest.leader'] = seniorMember;
+ }
+ }
+ update['$inc'] = {memberCount: -1};
+ Group.update({_id:group._id},update,cb);
+ }
+ ],function(err){
+ if (err) return next(err);
+ return res.send(204);
+ user = group = keep = null;
+ })
+}
+
+api.invite = function(req, res, next) {
+ var group = res.locals.group;
+ var uuid = req.query.uuid;
+
+ User.findById(uuid, function(err,invite){
+ if (err) return next(err);
+ if (!invite)
+ return res.json(400,{err:'User with id "' + uuid + '" not found'});
+ if (group.type == 'guild') {
+ if (_.contains(group.members,uuid))
+ return res.json(400,{err: "User already in that group"});
+ if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id}))
+ return res.json(400, {err:"User already invited to that group"});
+ sendInvite();
+ } else if (group.type == 'party') {
+ if (invite.invitations && !_.isEmpty(invite.invitations.party))
+ return res.json(400,{err:"User already pending invitation."});
+ Group.find({type:'party', members:{$in:[uuid]}}, function(err, groups){
+ if (err) return next(err);
+ if (!_.isEmpty(groups))
+ return res.json(400,{err:"User already in a party."})
+ sendInvite();
+ });
+ }
+
+ function sendInvite (){
+ if(group.type === 'guild'){
+ invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id});
+ }else{
+ //req.body.type in 'guild', 'party'
+ invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id};
+ }
+
+ group.invites.push(invite._id);
+
+ async.series([
+ function(cb){
+ invite.save(cb);
+ },
+ function(cb){
+ group.save(cb);
+ },
+ function(cb){
+ populateQuery(group.type, Group.findById(group._id)).exec(cb);
+ }
+ ], 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/public'}
+ );
+ }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;
+ });
+ }
+ });
+}
+
+api.removeMember = function(req, res, next){
+ var group = res.locals.group;
+ var uuid = req.query.uuid;
+ var user = res.locals.user;
+
+ if(group.leader !== user._id){
+ return res.json(401, {err: "Only group leader can remove a member!"});
+ }
+
+ if(_.contains(group.members, uuid)){
+ var update = {$pull:{members:uuid}};
+ if(group.quest && group.quest.members){
+ // remove member from quest
+ update['$unset'] = {};
+ update['$unset']['quest.members.' + uuid] = "";
+ // TODO: run cleanQuestProgress and return scroll to member if member was quest owner
+ }
+ update['$inc'] = {memberCount: -1};
+ Group.update({_id:group._id},update, function(err, saved){
+ if (err) return next(err);
+
+ // Sending an empty 204 because Group.update doesn't return the group
+ // see http://mongoosejs.com/docs/api.html#model_Model.update
+ return res.send(204);
+ });
+ }else if(_.contains(group.invites, uuid)){
+ User.findById(uuid, function(err,invited){
+ var invitations = invited.invitations;
+ if(group.type === 'guild'){
+ invitations.guilds.splice(_.indexOf(invitations.guilds, group._id), 1);
+ }else{
+ invitations.party = undefined;
+ }
+
+ async.series([
+ function(cb){
+ invited.save(cb);
+ },
+ function(cb){
+ Group.update({_id:group._id},{$pull:{invites:uuid}}, cb);
+ }
+ ], function(err, results){
+ if (err) return next(err);
+
+ // Sending an empty 204 because Group.update doesn't return the group
+ // see http://mongoosejs.com/docs/api.html#model_Model.update
+ return res.send(204);
+ group = uuid = null;
+ });
+
+ });
+ }else{
+ return res.json(400, {err: "User not found among group's members!"});
+ group = uuid = null;
+ }
+}
+
+// ------------------------------------
+// Quests
+// ------------------------------------
+
+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.'});
+ // 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.');
+
+ group.markModified('quest');
+
+ // Not ready yet, wait till everyone's accepted, rejected, or we force-start
+ var statuses = _.values(group.quest.members);
+ if (!force && (~statuses.indexOf(undefined) || ~statuses.indexOf(null))) {
+ return group.save(function(err,saved){
+ if (err) return next(err);
+ res.json(saved);
+ })
+ }
+
+ var parallel = [],
+ questMembers = {},
+ key = group.quest.key,
+ quest = shared.content.quests[key],
+ collected = quest.collect ? _.transform(quest.collect, function(m,v,k){m[k]=0}) : {};
+
+ _.each(group.members, function(m){
+ var updates = {$set:{},$inc:{'_v':1}};
+ if (m == group.quest.leader)
+ updates['$inc']['items.quests.'+key] = -1;
+ if (group.quest.members[m] == true) {
+ // See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322 , we need to *not* reset party.quest.progress.up
+ //updates['$set']['party.quest'] = Group.cleanQuestProgress({key:key,progress:{collect:collected}});
+ updates['$set']['party.quest.key'] = key;
+ updates['$set']['party.quest.progress.down'] = 0;
+ updates['$set']['party.quest.progress.collect'] = collected;
+ updates['$set']['party.quest.completed'] = null;
+ questMembers[m] = true;
+ } else {
+ updates['$set']['party.quest'] = Group.cleanQuestProgress();
+ }
+ parallel.push(function(cb2){
+ User.update({_id:m},updates,cb2);
+ });
+ })
+
+ group.quest.active = true;
+ if (quest.boss) {
+ group.quest.progress.hp = quest.boss.hp;
+ if (quest.boss.rage) group.quest.progress.rage = 0;
+ } else {
+ group.quest.progress.collect = collected;
+ }
+ group.quest.members = questMembers;
+ group.markModified('quest'); // members & progress.collect are both Mixed types
+ parallel.push(function(cb2){group.save(cb2)});
+
+ parallel.push(function(cb){
+ // Fetch user.auth to send email, then remove it from data sent to the client
+ populateQuery(group.type, Group.findById(group._id), 'auth.facebook auth.local').exec(cb);
+ });
+
+ async.parallel(parallel,function(err, results){
+ if (err) return next(err);
+
+ var lastIndex = results.length -1;
+ var groupClone = clone(group);
+
+ groupClone.members = results[lastIndex].members;
+
+ // Send quest started email and remove auth information
+ _.each(groupClone.members, function(user){
+
+ if(user.preferences.emailNotifications.questStarted !== false &&
+ user._id !== res.locals.user._id &&
+ group.quest.members[user._id] == true
+ ){
+ utils.txnEmail(user, 'quest-started', [
+ {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'}
+ ]);
+ }
+
+ // Remove sensitive data from what is sent to the public
+ user.auth.facebook = undefined;
+ user.auth.local = undefined;
+ });
+
+ group = null;
+
+ return res.json(groupClone);
+ });
+}
+
+api.questAccept = function(req, res, next) {
+ var group = res.locals.group;
+ var user = res.locals.user;
+ var key = req.query.key;
+
+ if (!group) return res.json(400, {err: "Must be in a party to start quests."});
+
+ // If ?key=xxx is provided, we're starting a new quest and inviting the party. Otherwise, we're a party member accepting the invitation
+ if (key) {
+ var quest = shared.content.quests[key];
+ if (!quest) return res.json(404,{err:'Quest ' + key + ' not found'});
+ if (quest.lvl && user.stats.lvl < quest.lvl) return res.json(400, {err: "You must be level "+quest.lvl+" to begin this quest."});
+ if (group.quest.key) return res.json(400, {err: 'Party already on a quest (and only have one quest at a time)'});
+ if (!user.items.quests[key]) return res.json(400, {err: "You don't own that quest scroll"});
+ group.quest.key = key;
+ group.quest.members = {};
+ // Invite everyone. true means "accepted", false="rejected", undefined="pending". Once we click "start quest"
+ // or everyone has either accepted/rejected, then we store quest key in user object.
+ _.each(group.members, function(m){
+ if (m == user._id) {
+ group.quest.members[m] = true;
+ group.quest.leader = user._id;
+ } else {
+ group.quest.members[m] = undefined;
+ }
+ });
+
+ User.find({
+ _id: {
+ $in: _.without(group.members, user._id)
+ }
+ }, {auth: 1, preferences: 1, profile: 1}, function(err, members){
+ if(err) return next(err);
+
+ var inviterName = utils.getUserInfo(user, ['name']).name;
+
+ _.each(members, function(member){
+ if(member.preferences.emailNotifications.invitedQuest !== false){
+ utils.txnEmail(member, ('invite-' + (quest.boss ? 'boss' : 'collection') + '-quest'), [
+ {name: 'QUEST_NAME', content: quest.text()},
+ {name: 'INVITER', content: inviterName},
+ {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'}
+ ]);
+ }
+ });
+
+ questStart(req,res,next);
+ });
+
+ // Party member accepting the invitation
+ } else {
+ if (!group.quest.key) return res.json(400,{err:'No quest invitation has been sent out yet.'});
+ group.quest.members[user._id] = true;
+ questStart(req,res,next);
+ }
+}
+
+api.questReject = function(req, res, next) {
+ var group = res.locals.group;
+ var user = res.locals.user;
+
+ if (!group.quest.key) return res.json(400,{err:'No quest invitation has been sent out yet.'});
+ group.quest.members[user._id] = false;
+ questStart(req,res,next);
+}
+
+api.questCancel = function(req, res, next){
+ // Cancel a quest BEFORE it has begun (i.e., in the invitation stage)
+ // Quest scroll has not yet left quest owner's inventory so no need to return it.
+ // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started.
+ var group = res.locals.group;
+ async.parallel([
+ function(cb){
+ if (! group.quest.active) {
+ // Do not cancel active quests because this function does
+ // not do the clean-up required for that.
+ // TODO: return an informative error when quest is active
+ group.quest = {key:null,progress:{},leader:null};
+ group.markModified('quest');
+ group.save(cb);
+ }
+ }
+ ], function(err){
+ if (err) return next(err);
+ res.json(group);
+ group = null;
+ })
+}
+
+api.questAbort = function(req, res, next){
+ // Abort a quest AFTER it has begun (see questCancel for BEFORE)
+ var group = res.locals.group;
+ async.parallel([
+ function(cb){
+ User.update(
+ {_id:{$in: _.keys(group.quest.members)}},
+ {
+ $set: {'party.quest':Group.cleanQuestProgress()},
+ $inc: {_v:1}
+ },
+ {multi:true},
+ cb);
+ },
+ // Refund party leader quest scroll
+ function(cb){
+ if (group.quest.active) {
+ var update = {$inc:{}};
+ update['$inc']['items.quests.' + group.quest.key] = 1;
+ User.update({_id:group.quest.leader}, update).exec();
+ }
+ group.quest = {key:null,progress:{},leader:null};
+ group.markModified('quest');
+ group.save(cb);
+ }, function(cb){
+ populateQuery(group.type, Group.findById(group._id)).exec(cb);
+ }
+ ], function(err, results){
+ if (err) return next(err);
+
+ var groupClone = clone(group);
+
+ groupClone.members = results[2].members;
+
+ res.json(groupClone);
+ group = null;
+ })
+}
diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js
new file mode 100644
index 0000000000..fe719bb66e
--- /dev/null
+++ b/src/controllers/payments/index.js
@@ -0,0 +1,156 @@
+/* @see ./routes.coffee for routing*/
+var _ = require('lodash');
+var shared = require('habitrpg-shared');
+var nconf = require('nconf');
+var utils = require('./../../utils');
+var moment = require('moment');
+var isProduction = nconf.get("NODE_ENV") === "production";
+var stripe = require('./stripe');
+var paypal = require('./paypal');
+var members = require('../members')
+var async = require('async');
+var iap = require('./iap');
+var mongoose= require('mongoose');
+var cc = require('coupon-code');
+
+function revealMysteryItems(user) {
+ _.each(shared.content.gear.flat, function(item) {
+ if (
+ item.klass === 'mystery' &&
+ moment().isAfter(shared.content.mystery[item.mystery].start) &&
+ moment().isBefore(shared.content.mystery[item.mystery].end) &&
+ !user.items.gear.owned[item.key] &&
+ !~user.purchased.plan.mysteryItems.indexOf(item.key)
+ ) {
+ user.purchased.plan.mysteryItems.push(item.key);
+ }
+ });
+}
+
+exports.createSubscription = function(data, cb) {
+ var recipient = data.gift ? data.gift.member : data.user;
+ //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // FIXME double-check, this should never be the case
+ var p = recipient.purchased.plan;
+ var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key];
+ var months = +block.months;
+
+ if (data.gift) {
+ if (p.customerId && !p.dateTerminated) { // User has active plan
+ p.extraMonths += months;
+ } else {
+ p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate();
+ if (!p.dateUpdated) p.dateUpdated = new Date();
+ }
+ if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId
+ } else {
+ _(p).merge({ // override with these values
+ planId: block.key,
+ customerId: data.customerId,
+ dateUpdated: new Date(),
+ gemsBought: 0,
+ paymentMethod: data.paymentMethod,
+ extraMonths: +p.extraMonths
+ + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0),
+ dateTerminated: null
+ }).defaults({ // allow non-override if a plan was previously used
+ dateCreated: new Date(),
+ mysteryItems: []
+ });
+ }
+
+ // Block sub perks
+ var perks = Math.floor(months/3);
+ if (perks) {
+ p.consecutive.offset += months;
+ p.consecutive.gemCapExtra += perks*5;
+ if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25;
+ p.consecutive.trinkets += perks;
+ }
+ revealMysteryItems(recipient);
+ if(isProduction) {
+ if (!data.gift) utils.txnEmail(data.user, 'subscription-begins');
+ utils.ga.event('subscribe', data.paymentMethod).send();
+ 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.member.preferences.emailNotifications.giftedSubscription !== false){
+ utils.txnEmail(data.gift.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);}
+ ], cb);
+}
+
+/**
+ * Sets their subscription to be cancelled later
+ */
+exports.cancelSubscription = function(data, cb) {
+ var p = data.user.purchased.plan,
+ now = moment(),
+ remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30;
+
+ p.dateTerminated =
+ moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') )
+ .add({days: remaining}) // end their subscription 1mo from their last payment
+ .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions...
+ .toDate();
+ p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
+
+ data.user.save(cb);
+ utils.txnEmail(data.user, 'cancel-subscription');
+ utils.ga.event('unsubscribe', data.paymentMethod).send();
+}
+
+exports.buyGems = function(data, cb) {
+ var amt = data.gift ? data.gift.gems.amount/4 : 5;
+ (data.gift ? data.gift.member : data.user).balance += amt;
+ data.user.purchased.txnCount++;
+ if(isProduction) {
+ if (!data.gift) utils.txnEmail(data.user, 'donation');
+ utils.ga.event('checkout', data.paymentMethod).send();
+ //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.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}
+ ]);
+ }
+ }
+ async.parallel([
+ function(cb2){data.user.save(cb2)},
+ function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);}
+ ], cb);
+}
+
+exports.validCoupon = function(req, res, next){
+ mongoose.model('Coupon').findOne({_id:cc.validate(req.params.code), event:'google_6mo'}, function(err, coupon){
+ if (err) return next(err);
+ if (!coupon) return res.json(401, {err:"Invalid coupon code"});
+ return res.send(200);
+ });
+}
+
+exports.stripeCheckout = stripe.checkout;
+exports.stripeSubscribeCancel = stripe.subscribeCancel;
+exports.stripeSubscribeEdit = stripe.subscribeEdit;
+
+exports.paypalSubscribe = paypal.createBillingAgreement;
+exports.paypalSubscribeSuccess = paypal.executeBillingAgreement;
+exports.paypalSubscribeCancel = paypal.cancelSubscription;
+exports.paypalCheckout = paypal.createPayment;
+exports.paypalCheckoutSuccess = paypal.executePayment;
+exports.paypalIPN = paypal.ipn;
+
+exports.iapAndroidVerify = iap.androidVerify;
+exports.iapIosVerify = iap.iosVerify;
\ No newline at end of file
diff --git a/src/controllers/user.js b/src/controllers/user.js
new file mode 100644
index 0000000000..ae2987fb8c
--- /dev/null
+++ b/src/controllers/user.js
@@ -0,0 +1,572 @@
+/* @see ./routes.coffee for routing*/
+
+var url = require('url');
+var ipn = require('paypal-ipn');
+var _ = require('lodash');
+var nconf = require('nconf');
+var async = require('async');
+var shared = require('habitrpg-shared');
+var User = require('./../models/user').model;
+var utils = require('./../utils');
+var ga = utils.ga;
+var Group = require('./../models/group').model;
+var Challenge = require('./../models/challenge').model;
+var moment = require('moment');
+var logging = require('./../logging');
+var acceptablePUTPaths;
+var api = module.exports;
+var qs = require('qs');
+var request = require('request');
+var validator = require('validator');
+
+// api.purchase // Shared.ops
+
+api.getContent = function(req, res, next) {
+ var language = 'en';
+
+ if(typeof req.query.language != 'undefined')
+ language = req.query.language.toString(); //|| 'en' in i18n
+
+ var content = _.cloneDeep(shared.content);
+ var walk = function(obj, lang){
+ _.each(obj, function(item, key, source){
+ if(_.isPlainObject(item) || _.isArray(item)) return walk(item, lang);
+ if(_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang);
+ });
+ }
+ walk(content, language);
+ res.json(content);
+}
+
+api.getModelPaths = function(req,res,next){
+ res.json(_.reduce(User.schema.paths,function(m,v,k){
+ m[k] = v.instance || 'Boolean';
+ return m;
+ },{}));
+}
+
+/*
+ ------------------------------------------------------------------------
+ Tasks
+ ------------------------------------------------------------------------
+*/
+
+
+/*
+ Local Methods
+ ---------------
+*/
+
+var findTask = function(req, res) {
+ return res.locals.user.tasks[req.params.id];
+};
+
+/*
+ API Routes
+ ---------------
+*/
+
+/**
+ This is called form deprecated.coffee's score function, and the req.headers are setup properly to handle the login
+ Export it also so we can call it from deprecated.coffee
+*/
+api.score = function(req, res, next) {
+ var id = req.params.id,
+ direction = req.params.direction,
+ user = res.locals.user,
+ task;
+
+ var clearMemory = function(){user = task = id = direction = null;}
+
+ // Send error responses for improper API call
+ if (!id) return res.json(400, {err: ':id required'});
+ if (direction !== 'up' && direction !== 'down') {
+ if (direction == 'unlink' || direction == 'sort') return next();
+ return res.json(400, {err: ":direction must be 'up' or 'down'"});
+ }
+ // If exists already, score it
+ if (task = user.tasks[id]) {
+ // Set completed if type is daily or todo and task exists
+ if (task.type === 'daily' || task.type === 'todo') {
+ task.completed = direction === 'up';
+ }
+ } else {
+ // If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it
+ // Defaults. Other defaults are handled in user.ops.addTask()
+ task = {
+ id: id,
+ type: req.body && req.body.type,
+ text: req.body && req.body.text,
+ notes: (req.body && req.body.notes) || "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task."
+ };
+ task = user.ops.addTask({body:task});
+ if (task.type === 'daily' || task.type === 'todo')
+ task.completed = direction === 'up';
+ }
+ var delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language});
+
+ user.save(function(err,saved){
+ if (err) return next(err);
+ // TODO this should be return {_v,task,stats,_tmp}, instead of merging everything togther at top-level response
+ // However, this is the most commonly used API route, and changing it will mess with all 3rd party consumers. Bad idea :(
+ res.json(200, _.extend({
+ delta: delta,
+ _tmp: user._tmp
+ }, saved.toJSON().stats));
+
+ // Webhooks
+ _.each(user.preferences.webhooks, function(h){
+ if (!h.enabled || !validator.isURL(h.url)) return;
+ request.post({
+ url: h.url,
+ //form: {task: task, delta: delta, user: _.pick(user, ['stats', '_tmp'])} // this is causing "Maximum Call Stack Exceeded"
+ body: {direction:direction, task: task, delta: delta, user: _.pick(user, ['_id', 'stats', '_tmp'])}, json:true
+ });
+ });
+
+ if (
+ (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there
+ || (task.type == 'reward') // we don't want to update the reward GP cost
+ ) return clearMemory();
+ Challenge.findById(task.challenge.id, 'habits dailys todos rewards', function(err, chal){
+ if (err) return next(err);
+ if (!chal) {
+ task.challenge.broken = 'CHALLENGE_DELETED';
+ user.save();
+ return clearMemory();
+ }
+ var t = chal.tasks[task.id];
+ // this task was removed from the challenge, notify user
+ if (!t) {
+ chal.syncToUser(user);
+ return clearMemory();
+ }
+ t.value += delta;
+ if (t.type == 'habit' || t.type == 'daily')
+ t.history.push({value: t.value, date: +new Date});
+ chal.save();
+ clearMemory();
+ });
+ });
+};
+
+/**
+ * Get all tasks
+ */
+api.getTasks = function(req, res, next) {
+ var user = res.locals.user;
+ if (req.query.type) {
+ return res.json(user[req.query.type+'s']);
+ } else {
+ return res.json(_.toArray(user.tasks));
+ }
+};
+
+/**
+ * Get Task
+ */
+api.getTask = function(req, res, next) {
+ var task = findTask(req,res);
+ if (!task) return res.json(404, {err: "No task found."});
+ return res.json(200, task);
+};
+
+
+/*
+ Update Task
+*/
+
+//api.deleteTask // see Shared.ops
+// api.updateTask // handled in Shared.ops
+// api.addTask // handled in Shared.ops
+// api.sortTask // handled in Shared.ops #TODO updated api, mention in docs
+
+/*
+ ------------------------------------------------------------------------
+ Items
+ ------------------------------------------------------------------------
+*/
+// api.buy // handled in Shard.ops
+
+api.getBuyList = function (req, res, next) {
+ var list = shared.updateStore(res.locals.user);
+ return res.json(200, list);
+};
+
+/*
+ ------------------------------------------------------------------------
+ User
+ ------------------------------------------------------------------------
+*/
+
+/**
+ * Get User
+ */
+api.getUser = function(req, res, next) {
+ var user = res.locals.user.toJSON();
+ user.stats.toNextLevel = shared.tnl(user.stats.lvl);
+ user.stats.maxHealth = 50;
+ user.stats.maxMP = res.locals.user._statsComputed.maxMP;
+ delete user.apiToken;
+ if (user.auth) {
+ delete user.auth.hashed_password;
+ delete user.auth.salt;
+ }
+ return res.json(200, user);
+};
+
+
+/**
+ * This tells us for which paths users can call `PUT /user` (or batch-update equiv, which use `User.set()` on our client).
+ * The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs)
+ * FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations
+ */
+acceptablePUTPaths = _.reduce(require('./../models/user').schema.paths, function(m,v,leaf){
+ var found= _.find('achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '), function(root){
+ return leaf.indexOf(root) == 0;
+ });
+ if (found) m[leaf]=true;
+ return m;
+}, {})
+
+//// Uncomment this if we we want to disable GP-restoring (eg, holiday events)
+//_.each('stats.gp'.split(' '), function(removePath){
+// delete acceptablePUTPaths[removePath];
+//})
+
+/**
+ * Update user
+ * Send up PUT /user as `req.body={path1:val, path2:val, etc}`. Example:
+ * PUT /user {'stats.hp':50, 'tasks.TASK_ID.repeat.m':false}
+ * See acceptablePUTPaths for which user paths are supported
+*/
+api.update = function(req, res, next) {
+ var user = res.locals.user;
+ var errors = [];
+ if (_.isEmpty(req.body)) return res.json(200, user);
+
+ _.each(req.body, function(v, k) {
+ if (acceptablePUTPaths[k])
+ user.fns.dotSet(k, v);
+ else
+ errors.push("path `" + k + "` was not saved, as it's a protected path. See https://github.com/HabitRPG/habitrpg/blob/develop/API.md for PUT /api/v2/user.");
+ return true;
+ });
+ user.save(function(err) {
+ if (!_.isEmpty(errors)) return res.json(401, {err: errors});
+ if (err) return next(err);
+ res.json(200, user);
+ user = errors = null;
+ });
+};
+
+api.cron = function(req, res, next) {
+ var user = res.locals.user,
+ progress = user.fns.cron(),
+ ranCron = user.isModified(),
+ quest = shared.content.quests[user.party.quest.key];
+
+ if (ranCron) res.locals.wasModified = true;
+ if (!ranCron) return next(null,user);
+ Group.tavernBoss(user,progress);
+ if (!quest) return user.save(next);
+
+ // If user is on a quest, roll for boss & player, or handle collections
+ // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
+ async.waterfall([
+ function(cb){
+ user.save(cb); // make sure to save the cron effects
+ },
+ function(saved, count, cb){
+ var type = quest.boss ? 'boss' : 'collect';
+ Group[type+'Quest'](user,progress,cb);
+ },
+ function(){
+ var cb = arguments[arguments.length-1];
+ // User has been updated in boss-grapple, reload
+ User.findById(user._id, cb);
+ }
+ ], function(err, saved) {
+ res.locals.user = saved;
+ next(err,saved);
+ user = progress = quest = null;
+ });
+};
+
+// api.reroll // Shared.ops
+// api.reset // Shared.ops
+
+api['delete'] = function(req, res, next) {
+ var plan = res.locals.user.purchased.plan;
+ if (plan && plan.customerId && !plan.dateTerminated)
+ return res.json(400,{err:"You have an active subscription, cancel your plan before deleting your account."});
+ res.locals.user.remove(function(err){
+ if (err) return next(err);
+ res.send(200);
+ })
+}
+
+/*
+ ------------------------------------------------------------------------
+ Gems
+ ------------------------------------------------------------------------
+ */
+
+// api.unlock // see Shared.ops
+
+api.addTenGems = function(req, res, next) {
+ var user = res.locals.user;
+ user.balance += 2.5;
+ user.save(function(err){
+ if (err) return next(err);
+ res.send(204);
+ })
+}
+
+/*
+ ------------------------------------------------------------------------
+ Tags
+ ------------------------------------------------------------------------
+ */
+// api.deleteTag // handled in Shared.ops
+// api.addTag // handled in Shared.ops
+// api.updateTag // handled in Shared.ops
+// api.sortTag // handled in Shared.ops
+
+/*
+ ------------------------------------------------------------------------
+ Spells
+ ------------------------------------------------------------------------
+ */
+api.cast = function(req, res, next) {
+ var user = res.locals.user,
+ targetType = req.query.targetType,
+ targetId = req.query.targetId,
+ klass = shared.content.spells.special[req.params.spell] ? 'special' : user.stats.class,
+ spell = shared.content.spells[klass][req.params.spell];
+
+ if (!spell) return res.json(404, {err: 'Spell "' + req.params.spell + '" not found.'});
+ if (spell.mana > user.stats.mp) return res.json(400, {err: 'Not enough mana to cast spell'});
+
+ var done = function(){
+ var err = arguments[0];
+ var saved = _.size(arguments == 3) ? arguments[2] : arguments[1];
+ if (err) return next(err);
+ res.json(saved);
+ user = targetType = targetId = klass = spell = null;
+ }
+
+ switch (targetType) {
+ case 'task':
+ if (!user.tasks[targetId]) return res.json(404, {err: 'Task "' + targetId + '" not found.'});
+ spell.cast(user, user.tasks[targetId]);
+ user.save(done);
+ break;
+
+ case 'self':
+ spell.cast(user);
+ user.save(done);
+ break;
+
+ case 'party':
+ case 'user':
+ async.waterfall([
+ function(cb){
+ Group.findOne({type: 'party', members: {'$in': [user._id]}}).populate('members', 'profile.name stats achievements items.special').exec(cb);
+ },
+ function(group, cb) {
+ // Solo player? let's just create a faux group for simpler code
+ var g = group ? group : {members:[user]};
+ var series = [], found;
+ if (targetType == 'party') {
+ spell.cast(user, g.members);
+ series = _.transform(g.members, function(m,v,k){
+ m.push(function(cb2){v.save(cb2)});
+ });
+ } else {
+ found = _.find(g.members, {_id: targetId})
+ spell.cast(user, found);
+ series.push(function(cb2){found.save(cb2)});
+ }
+
+ if (group) {
+ series.push(function(cb2){
+ var message = '`'+user.profile.name+' casts '+spell.text() + (targetType=='user' ? ' on '+found.profile.name : ' for the party')+'.`';
+ group.sendChat(message);
+ group.save(cb2);
+ })
+ }
+
+ series.push(function(cb2){g = group = series = found = null;cb2();})
+
+ async.series(series, cb);
+ },
+ function(whatever, cb){
+ user.save(cb);
+ }
+ ], done);
+ break;
+ }
+}
+
+/**
+ * POST /user/invite-friends
+ */
+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);
+
+ _.each(req.body.emails, function(invite){
+ if (invite.email) {
+
+ 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 || (userToContact.preferences.emailNotifications.invitedParty !== false &&
+ userToContact.preferences.emailNotifications.unsubscribeFromAll !== true)){
+ // TODO implement "users can only be invited once"
+ utils.txnEmail(invite, 'invite-friend', variables);
+ }
+ });
+
+ }
+ });
+ res.send(200);
+ })
+}
+
+api.sessionPartyInvite = function(req,res,next){
+ if (!req.session.partyInvite) return next();
+ var inv = res.locals.user.invitations;
+ if (inv.party && inv.party.id) return next(); // already invited to a party
+ async.waterfall([
+ function(cb){
+ Group.findOne({_id:req.session.partyInvite.id, type:'party', members:{$in:[req.session.partyInvite.inviter]}})
+ .select('invites members').exec(cb);
+ },
+ function(group, cb){
+ if (!group){
+ // Don't send error as it will prevent users from using the site
+ delete req.session.partyInvite;
+ return cb();
+ }
+ inv.party = req.session.partyInvite;
+ delete req.session.partyInvite;
+ if (!~group.invites.indexOf(res.locals.user._id))
+ group.invites.push(res.locals.user._id); //$addToSt
+ group.save(cb);
+ },
+ function(saved, cb){
+ res.locals.user.save(cb);
+ }
+ ], next);
+}
+
+/**
+ * All other user.ops which can easily be mapped to habitrpg-shared/index.coffee, not requiring custom API-wrapping
+ */
+_.each(shared.wrap({}).ops, function(op,k){
+ if (!api[k]) {
+ api[k] = function(req, res, next) {
+ res.locals.user.ops[k](req,function(err, response){
+ // If we want to send something other than 500, pass err as {code: 200, message: "Not enough GP"}
+ if (err) {
+ if (!err.code) return next(err);
+ if (err.code >= 400) return res.json(err.code,{err:err.message});
+ // In the case of 200s, they're friendly alert messages like "You're pet has hatched!" - still send the op
+ }
+ res.locals.user.save(function(err){
+ if (err) return next(err);
+ res.json(200,response);
+ })
+ }, ga);
+ }
+ }
+})
+
+/*
+ ------------------------------------------------------------------------
+ Batch Update
+ Run a bunch of updates all at once
+ ------------------------------------------------------------------------
+*/
+api.batchUpdate = function(req, res, next) {
+ if (_.isEmpty(req.body)) req.body = []; // cases of {} or null
+ if (req.body[0] && req.body[0].data)
+ return res.json(501, {err: "API has been updated, please refresh your browser or upgrade your mobile app."})
+
+ var user = res.locals.user;
+ var oldSend = res.send;
+ var oldJson = res.json;
+
+ // Stash user.save, we'll queue the save op till the end (so we don't overload the server)
+ var oldSave = user.save;
+ user.save = function(cb){cb(null,user)}
+
+ // Setup the array of functions we're going to call in parallel with async
+ res.locals.ops = [];
+ var ops = _.transform(req.body, function(m,_req){
+ if (_.isEmpty(_req)) return;
+ _req.language = req.language;
+
+ m.push(function() {
+ var cb = arguments[arguments.length-1];
+ res.locals.ops.push(_req);
+ res.send = res.json = function(code, data) {
+ if (_.isNumber(code) && code >= 500)
+ return cb(code+": "+ (data.message ? data.message : data.err ? data.err : JSON.stringify(data)));
+ return cb();
+ };
+ api[_req.op](_req, res, cb);
+ });
+ })
+ // Finally, save user at the end
+ .concat(function(){
+ user.save = oldSave;
+ user.save(arguments[arguments.length-1]);
+ });
+
+ // call all the operations, then return the user object to the requester
+ async.waterfall(ops, function(err,_user) {
+ res.json = oldJson;
+ res.send = oldSend;
+ if (err) return next(err);
+
+ var response = _user.toJSON();
+ response.wasModified = res.locals.wasModified;
+
+ user.fns.nullify();
+ user = res.locals.user = oldSend = oldJson = oldSave = null;
+
+ // return only drops & streaks
+ if (response._tmp && response._tmp.drop){
+ res.json(200, {_tmp: {drop: response._tmp.drop}, _v: response._v});
+
+ // Fetch full user object
+ }else if(response.wasModified){
+ // Preen 3-day past-completed To-Dos from Angular & mobile app
+ response.todos = _.where(response.todos, function(t) {
+ return !t.completed || (t.challenge && t.challenge.id) || moment(t.dateCompleted).isAfter(moment().subtract({days:3}));
+ });
+ res.json(200, response);
+
+ // return only the version number
+ }else{
+ res.json(200, {_v: response._v});
+ }
+ });
+};
diff --git a/src/models/user.js b/src/models/user.js
new file mode 100644
index 0000000000..74444d4ff5
--- /dev/null
+++ b/src/models/user.js
@@ -0,0 +1,533 @@
+// User.js
+// =======
+// Defines the user data model (schema) for use via the API.
+
+// Dependencies
+// ------------
+var mongoose = require("mongoose");
+var Schema = mongoose.Schema;
+var shared = require('habitrpg-shared');
+var _ = require('lodash');
+var TaskSchemas = require('./task');
+var Challenge = require('./challenge').model;
+var moment = require('moment');
+
+// User Schema
+// -----------
+
+var UserSchema = new Schema({
+ // ### UUID and API Token
+ _id: {
+ type: String,
+ 'default': shared.uuid
+ },
+ apiToken: {
+ type: String,
+ 'default': shared.uuid
+ },
+
+ // ### Mongoose Update Object
+ // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which
+ // have been updated (http://goo.gl/gQLz41), but we want *every* update
+ _v: { type: Number, 'default': 0 },
+ achievements: {
+ originalUser: Boolean,
+ helpedHabit: Boolean,
+ ultimateGear: Boolean,
+ beastMaster: Boolean,
+ beastMasterCount: Number,
+ mountMaster: Boolean,
+ mountMasterCount: Number,
+ triadBingo: Boolean,
+ triadBingoCount: Number,
+ veteran: Boolean,
+ snowball: Number,
+ spookDust: Number,
+ streak: Number,
+ challenges: Array,
+ quests: Schema.Types.Mixed,
+ rebirths: Number,
+ rebirthLevel: Number,
+ perfect: Number,
+ habitBirthday: Boolean, // TODO: Deprecate this. Superseded by habitBirthdays
+ habitBirthdays: Number,
+ valentine: Number,
+ costumeContest: Boolean,
+ nye: Number
+ },
+ auth: {
+ blocked: Boolean,
+ facebook: Schema.Types.Mixed,
+ local: {
+ email: String,
+ hashed_password: String,
+ salt: String,
+ username: String
+ },
+ timestamps: {
+ created: {type: Date,'default': Date.now},
+ loggedin: {type: Date,'default': Date.now}
+ }
+ },
+
+ backer: {
+ tier: Number,
+ npc: String,
+ tokensApplied: Boolean
+ },
+
+ contributor: {
+ level: Number, // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801
+ admin: Boolean,
+ sudo: Boolean,
+ text: String, // Artisan, Friend, Blacksmith, etc
+ contributions: String, // a markdown textarea to list their contributions + links
+ critical: String
+ },
+
+ balance: {type: Number, 'default':0},
+ filters: {type: Schema.Types.Mixed, 'default': {}},
+
+ purchased: {
+ ads: {type: Boolean, 'default': false},
+ skin: {type: Schema.Types.Mixed, 'default': {}}, // eg, {skeleton: true, pumpkin: true, eb052b: true}
+ hair: {type: Schema.Types.Mixed, 'default': {}},
+ shirt: {type: Schema.Types.Mixed, 'default': {}},
+ background: {type: Schema.Types.Mixed, 'default': {}},
+ txnCount: {type: Number, 'default':0},
+ mobileChat: Boolean,
+ plan: {
+ planId: String,
+ paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', '']}
+ customerId: String,
+ dateCreated: Date,
+ dateTerminated: Date,
+ dateUpdated: Date,
+ extraMonths: {type:Number, 'default':0},
+ gemsBought: {type: Number, 'default': 0},
+ mysteryItems: {type: Array, 'default': []},
+ consecutive: {
+ count: {type:Number, 'default':0},
+ offset: {type:Number, 'default':0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0
+ gemCapExtra: {type:Number, 'default':0},
+ trinkets: {type:Number, 'default':0}
+ }
+ }
+ },
+
+ flags: {
+ customizationsNotification: {type: Boolean, 'default': false},
+ showTour: {type: Boolean, 'default': true},
+ dropsEnabled: {type: Boolean, 'default': false},
+ itemsEnabled: {type: Boolean, 'default': false},
+ newStuff: {type: Boolean, 'default': false},
+ rewrite: {type: Boolean, 'default': true},
+ partyEnabled: Boolean, // FIXME do we need this?
+ contributor: Boolean,
+ classSelected: {type: Boolean, 'default': false},
+ mathUpdates: Boolean,
+ rebirthEnabled: {type: Boolean, 'default': false},
+ freeRebirth: {type: Boolean, 'default': false},
+ levelDrops: {type:Schema.Types.Mixed, 'default':{}},
+ chatRevoked: Boolean,
+ // Used to track the status of recapture emails sent to each user,
+ // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user
+ recaptureEmailsPhase: {type: Number, 'default': 0},
+ communityGuidelinesAccepted: {type: Boolean, 'default': false}
+ },
+ history: {
+ exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined
+ todos: Array //[{data: Date, value: Number}] // big peformance issues if these are defined
+ },
+
+ // FIXME remove?
+ invitations: {
+ guilds: {type: Array, 'default': []},
+ party: Schema.Types.Mixed
+ },
+ items: {
+ gear: {
+ owned: _.transform(shared.content.gear.flat, function(m,v,k){
+ m[v.key] = {type: Boolean};
+ if (v.key.match(/[weapon|armor|head|shield]_warrior_0/))
+ m[v.key]['default'] = true;
+ }),
+
+ equipped: {
+ weapon: {type: String, 'default': 'weapon_warrior_0'},
+ armor: {type: String, 'default': 'armor_base_0'},
+ head: {type: String, 'default': 'head_base_0'},
+ shield: {type: String, 'default': 'shield_base_0'},
+ back: String,
+ headAccessory: String,
+ eyewear: String,
+ body: String
+ },
+ costume: {
+ weapon: {type: String, 'default': 'weapon_base_0'},
+ armor: {type: String, 'default': 'armor_base_0'},
+ head: {type: String, 'default': 'head_base_0'},
+ shield: {type: String, 'default': 'shield_base_0'},
+ back: String,
+ headAccessory: String,
+ eyewear: String,
+ body: String
+ },
+ },
+
+ special:{
+ snowball: {type: Number, 'default': 0},
+ spookDust: {type: Number, 'default': 0},
+ valentine: Number,
+ valentineReceived: Array, // array of strings, by sender name
+ nye: Number,
+ nyeReceived: Array
+ },
+
+ // -------------- Animals -------------------
+ // Complex bit here. The result looks like:
+ // pets: {
+ // 'Wolf-Desert': 0, // 0 means does not own
+ // 'PandaCub-Red': 10, // Number represents "Growth Points"
+ // etc...
+ // }
+ pets:
+ _.defaults(
+ // First transform to a 1D eggs/potions mapping
+ _.transform(shared.content.pets, function(m,v,k){ m[k] = Number; }),
+ // Then add quest pets
+ _.transform(shared.content.questPets, function(m,v,k){ m[k] = Number; }),
+ // Then add additional pets (backer, contributor)
+ _.transform(shared.content.specialPets, function(m,v,k){ m[k] = Number; })
+ ),
+ currentPet: String, // Cactus-Desert
+
+ // eggs: {
+ // 'PandaCub': 0, // 0 indicates "doesn't own"
+ // 'Wolf': 5 // Number indicates "stacking"
+ // }
+ eggs: _.transform(shared.content.eggs, function(m,v,k){ m[k] = Number; }),
+
+ // hatchingPotions: {
+ // 'Desert': 0, // 0 indicates "doesn't own"
+ // 'CottonCandyBlue': 5 // Number indicates "stacking"
+ // }
+ hatchingPotions: _.transform(shared.content.hatchingPotions, function(m,v,k){ m[k] = Number; }),
+
+ // Food: {
+ // 'Watermelon': 0, // 0 indicates "doesn't own"
+ // 'RottenMeat': 5 // Number indicates "stacking"
+ // }
+ food: _.transform(shared.content.food, function(m,v,k){ m[k] = Number; }),
+
+ // mounts: {
+ // 'Wolf-Desert': true,
+ // 'PandaCub-Red': false,
+ // etc...
+ // }
+ mounts: _.defaults(
+ // First transform to a 1D eggs/potions mapping
+ _.transform(shared.content.pets, function(m,v,k){ m[k] = Boolean; }),
+ // Then add quest pets
+ _.transform(shared.content.questPets, function(m,v,k){ m[k] = Boolean; }),
+ // Then add additional pets (backer, contributor)
+ _.transform(shared.content.specialMounts, function(m,v,k){ m[k] = Boolean; })
+ ),
+ currentMount: String,
+
+ // Quests: {
+ // 'boss_0': 0, // 0 indicates "doesn't own"
+ // 'collection_honey': 5 // Number indicates "stacking"
+ // }
+ quests: _.transform(shared.content.quests, function(m,v,k){ m[k] = Number; }),
+
+ lastDrop: {
+ date: {type: Date, 'default': Date.now},
+ count: {type: Number, 'default': 0}
+ }
+ },
+
+ lastCron: {type: Date, 'default': Date.now},
+
+ // {GROUP_ID: Boolean}, represents whether they have unseen chat messages
+ newMessages: {type: Schema.Types.Mixed, 'default': {}},
+
+ party: {
+ // id // FIXME can we use a populated doc instead of fetching party separate from user?
+ order: {type:String, 'default':'level'},
+ orderAscending: {type:String, 'default':'ascending'},
+ quest: {
+ key: String,
+ progress: {
+ up: {type: Number, 'default': 0},
+ down: {type: Number, 'default': 0},
+ collect: {type: Schema.Types.Mixed, 'default': {}} // {feather:1, ingot:2}
+ },
+ completed: String // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser
+ }
+ },
+ preferences: {
+ armorSet: String,
+ dayStart: {type:Number, 'default': 0, min: 0, max: 23},
+ size: {type:String, enum: ['broad','slim'], 'default': 'slim'},
+ hair: {
+ color: {type: String, 'default': 'red'},
+ base: {type: Number, 'default': 3},
+ bangs: {type: Number, 'default': 1},
+ beard: {type: Number, 'default': 0},
+ mustache: {type: Number, 'default': 0},
+ flower: {type: Number, 'default': 1}
+ },
+ hideHeader: {type:Boolean, 'default':false},
+ skin: {type:String, 'default':'915533'},
+ shirt: {type: String, 'default': 'blue'},
+ timezoneOffset: Number,
+ sound: {type:String, 'default':'off', enum: ['off','danielTheBard', 'wattsTheme']},
+ language: String,
+ automaticAllocation: Boolean,
+ allocationMode: {type:String, enum: ['flat','classbased','taskbased'], 'default': 'flat'},
+ costume: Boolean,
+ dateFormat: {type: String, enum:['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], 'default': 'MM/dd/yyyy'},
+ sleep: {type: Boolean, 'default': false},
+ stickyHeader: {type: Boolean, 'default': true},
+ disableClasses: {type: Boolean, 'default': false},
+ newTaskEdit: {type: Boolean, 'default': false},
+ dailyDueDefaultView: {type: Boolean, 'default': false},
+ tagsCollapsed: {type: Boolean, 'default': false},
+ advancedCollapsed: {type: Boolean, 'default': false},
+ toolbarCollapsed: {type:Boolean, 'default':false},
+ background: String,
+ 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},
+ questStarted: {type: Boolean, 'default': true},
+ invitedQuest: {type: Boolean, 'default': true},
+ //remindersToLogin: {type: Boolean, 'default': true},
+ importantAnnouncements: {type: Boolean, 'default': true}
+ }
+ },
+ profile: {
+ blurb: String,
+ imageUrl: String,
+ name: String,
+ },
+ stats: {
+ hp: {type: Number, 'default': 50},
+ mp: {type: Number, 'default': 10},
+ exp: {type: Number, 'default': 0},
+ gp: {type: Number, 'default': 0},
+ lvl: {type: Number, 'default': 1},
+
+ // Class System
+ 'class': {type: String, enum: ['warrior','rogue','wizard','healer'], 'default': 'warrior'},
+ points: {type: Number, 'default': 0},
+ str: {type: Number, 'default': 0},
+ con: {type: Number, 'default': 0},
+ int: {type: Number, 'default': 0},
+ per: {type: Number, 'default': 0},
+ buffs: {
+ str: {type: Number, 'default': 0},
+ int: {type: Number, 'default': 0},
+ per: {type: Number, 'default': 0},
+ con: {type: Number, 'default': 0},
+ stealth: {type: Number, 'default': 0},
+ streaks: {type: Boolean, 'default': false},
+ snowball: {type: Boolean, 'default': false},
+ spookDust: {type: Boolean, 'default': false}
+ },
+ training: {
+ int: {type: Number, 'default': 0},
+ per: {type: Number, 'default': 0},
+ str: {type: Number, 'default': 0},
+ con: {type: Number, 'default': 0}
+ }
+ },
+
+ tags: {type: [{
+ _id: false,
+ id: { type: String, 'default': shared.uuid },
+ name: String,
+ challenge: String
+ }]},
+
+ challenges: [{type: 'String', ref:'Challenge'}],
+
+ inbox: {
+ newMessages: {type:Number, 'default':0},
+ blocks: {type:Array, 'default':[]},
+ messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist
+ optOut: {type:Boolean, 'default':false}
+ },
+
+ habits: {type:[TaskSchemas.HabitSchema]},
+ dailys: {type:[TaskSchemas.DailySchema]},
+ todos: {type:[TaskSchemas.TodoSchema]},
+ rewards: {type:[TaskSchemas.RewardSchema]},
+
+ extra: Schema.Types.Mixed
+
+}, {
+ strict: true,
+ minimize: false // So empty objects are returned
+});
+
+UserSchema.methods.deleteTask = function(tid) {
+ this.ops.deleteTask({params:{id:tid}},function(){}); // TODO remove this whole method, since it just proxies, and change all references to this method
+}
+
+UserSchema.methods.toJSON = function() {
+ var doc = this.toObject();
+ doc.id = doc._id;
+
+ // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove?
+ doc.filters = {};
+ doc._tmp = this._tmp; // be sure to send down drop notifs
+
+ return doc;
+};
+
+//UserSchema.virtual('tasks').get(function () {
+// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards);
+// var tasks = _.object(_.pluck(tasks,'id'), tasks);
+// return tasks;
+//});
+
+UserSchema.post('init', function(doc){
+ shared.wrap(doc);
+})
+
+UserSchema.pre('save', function(next) {
+
+ // Populate new users with default content
+ if (this.isNew){
+ //TODO for some reason this doesn't work here: `_.merge(this, shared.content.userDefaults);`
+ var self = this;
+ _.each(['habits', 'dailys', 'todos', 'rewards', 'tags'], function(taskType){
+ self[taskType] = _.map(shared.content.userDefaults[taskType], function(task){
+ var newTask = _.cloneDeep(task);
+
+ // Render task's text and notes in user's language
+ if(taskType === 'tags'){
+ // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here
+ newTask.id = shared.uuid();
+ newTask.name = newTask.name(self.preferences.language);
+ }else{
+ newTask.text = newTask.text(self.preferences.language);
+ newTask.notes = newTask.notes(self.preferences.language);
+
+ if(newTask.checklist){
+ newTask.checklist = _.map(newTask.checklist, function(checklistItem){
+ checklistItem.text = checklistItem.text(self.preferences.language);
+ return checklistItem;
+ });
+ }
+ }
+
+ return newTask;
+ });
+ });
+ }
+
+ //this.markModified('tasks');
+ if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) {
+ this.preferences.dayStart = 0;
+ }
+
+ if (!this.profile.name) {
+ var fb = this.auth.facebook;
+ this.profile.name =
+ (this.auth.local && this.auth.local.username) ||
+ (fb && (fb.displayName || fb.name || fb.username || (fb.first_name && fb.first_name + ' ' + fb.last_name))) ||
+ 'Anonymous';
+ }
+
+ // Determines if Beast Master should be awarded
+ var petCount = shared.countPets(_.reduce(this.items.pets,function(m,v){
+ //HOTFIX - Remove when solution is found, the first argument passed to reduce is a function
+ if(_.isFunction(v)) return m;
+ return m+(v?1:0)},0), this.items.pets);
+
+ if (petCount >= 90 || this.achievements.beastMasterCount > 0) {
+ this.achievements.beastMaster = true
+ }
+
+ // Determines if Mount Master should be awarded
+ var mountCount = shared.countMounts(_.reduce(this.items.mounts,function(m,v){
+ //HOTFIX - Remove when solution is found, the first argument passed to reduce is a function
+ if(_.isFunction(v)) return m;
+ return m+(v?1:0)},0), this.items.mounts);
+
+ if (mountCount >= 90 || this.achievements.mountMasterCount > 0) {
+ this.achievements.mountMaster = true
+ }
+
+ // Determines if Triad Bingo should be awarded
+
+ var triadCount = shared.countTriad(this.items.pets);
+
+ if ((mountCount >= 90 && triadCount >= 90) || this.achievements.triadBingoCount > 0) {
+ this.achievements.triadBingo = true;
+ }
+
+ // EXAMPLE CODE for allowing all existing and new players to be
+ // automatically granted an item during a certain time period:
+ // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01'))
+ // this.items.pets['JackOLantern-Base'] = 5;
+
+ //our own version incrementer
+ if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0;
+ this._v++;
+
+ next();
+});
+
+UserSchema.methods.unlink = function(options, cb) {
+ var cid = options.cid, keep = options.keep, tid = options.tid;
+ var self = this;
+ switch (keep) {
+ case 'keep':
+ self.tasks[tid].challenge = {};
+ break;
+ case 'remove':
+ self.deleteTask(tid);
+ break;
+ case 'keep-all':
+ _.each(self.tasks, function(t){
+ if (t.challenge && t.challenge.id == cid) {
+ t.challenge = {};
+ }
+ });
+ break;
+ case 'remove-all':
+ _.each(self.tasks, function(t){
+ if (t.challenge && t.challenge.id == cid) {
+ self.deleteTask(t.id);
+ }
+ })
+ break;
+ }
+ self.markModified('habits');
+ self.markModified('dailys');
+ self.markModified('todos');
+ self.markModified('rewards');
+ self.save(cb);
+}
+
+module.exports.schema = UserSchema;
+module.exports.model = mongoose.model("User", UserSchema);
+
+mongoose.model("User")
+ .find({'contributor.admin':true})
+ .sort('-contributor.level -backer.npc profile.name')
+ .select('profile contributor backer')
+ .exec(function(err,mods){
+ module.exports.mods = mods
+});
diff --git a/website/public/js/controllers/authCtrl.js b/website/public/js/controllers/authCtrl.js
index a167ecb309..6bdd4c0b83 100644
--- a/website/public/js/controllers/authCtrl.js
+++ b/website/public/js/controllers/authCtrl.js
@@ -15,7 +15,7 @@ angular.module('habitrpg')
var runAuth = function(id, token) {
User.authenticate(id, token, function(err) {
- $window.location.href = '/';
+ $window.location.href = ('/' + window.location.hash);
});
};
@@ -57,7 +57,7 @@ angular.module('habitrpg')
$scope.playButtonClick = function(){
window.ga && ga('send', 'event', 'button', 'click', 'Play');
if (User.authenticated()) {
- window.location.href = '/#/tasks';
+ window.location.href = ('/' + window.location.hash);
} else {
$modal.open({
templateUrl: 'modals/login.html'
diff --git a/website/public/js/services/guideServices.js b/website/public/js/services/guideServices.js
index 74d710dbe5..45f02161e0 100644
--- a/website/public/js/services/guideServices.js
+++ b/website/public/js/services/guideServices.js
@@ -12,9 +12,11 @@ function($rootScope, User, $timeout, $state) {
* this because we need to determine whether to show the tour *after* the user has been pulled from the server,
* otherwise it's always start off as true, and then get set to false later
*/
+ var tourRunning = false;
$rootScope.$on('userUpdated', initTour);
function initTour(){
- if (User.user.flags.showTour === false) return;
+ if (User.user.flags.showTour === false || tourRunning) return;
+ tourRunning = true;
var tourSteps = [
{
orphan:true,
diff --git a/website/src/controllers/groups.js b/website/src/controllers/groups.js
index b6919a386c..a34c552b52 100644
--- a/website/src/controllers/groups.js
+++ b/website/src/controllers/groups.js
@@ -30,10 +30,12 @@ var guildPopulate = {path: 'members', select: nameFields, options: {limit: 15} }
* limited fields - and only a sampling of the members, beacuse they can be in the thousands
* @param type: 'party' or otherwise
* @param q: the Mongoose query we're building up
+ * @param additionalFields: if we want to populate some additional field not fetched normally
+ * pass it as a string, parties only
*/
-var populateQuery = function(type, q){
+var populateQuery = function(type, q, additionalFields){
if (type == 'party')
- q.populate('members', partyFields);
+ q.populate('members', partyFields + (additionalFields ? (' ' + additionalFields) : ''));
else
q.populate(guildPopulate);
q.populate('invites', nameFields);
@@ -700,7 +702,8 @@ questStart = function(req, res, next) {
parallel.push(function(cb2){group.save(cb2)});
parallel.push(function(cb){
- populateQuery(group.type, Group.findById(group._id)).exec(cb);
+ // Fetch user.auth to send email, then remove it from data sent to the client
+ populateQuery(group.type, Group.findById(group._id), 'auth.facebook auth.local').exec(cb);
});
async.parallel(parallel,function(err, results){
@@ -711,7 +714,25 @@ questStart = function(req, res, next) {
groupClone.members = results[lastIndex].members;
+ // Send quest started email and remove auth information
+ _.each(groupClone.members, function(user){
+
+ if(user.preferences.emailNotifications.questStarted !== false &&
+ user._id !== res.locals.user._id &&
+ group.quest.members[user._id] == true
+ ){
+ utils.txnEmail(user, 'quest-started', [
+ {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'}
+ ]);
+ }
+
+ // Remove sensitive data from what is sent to the public
+ user.auth.facebook = undefined;
+ user.auth.local = undefined;
+ });
+
group = null;
+
return res.json(groupClone);
});
}
@@ -743,12 +764,34 @@ api.questAccept = function(req, res, next) {
}
});
+ User.find({
+ _id: {
+ $in: _.without(group.members, user._id)
+ }
+ }, {auth: 1, preferences: 1, profile: 1}, function(err, members){
+ if(err) return next(err);
+
+ var inviterName = utils.getUserInfo(user, ['name']).name;
+
+ _.each(members, function(member){
+ if(member.preferences.emailNotifications.invitedQuest !== false){
+ utils.txnEmail(member, ('invite-' + (quest.boss ? 'boss' : 'collection') + '-quest'), [
+ {name: 'QUEST_NAME', content: quest.text()},
+ {name: 'INVITER', content: inviterName},
+ {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'}
+ ]);
+ }
+ });
+
+ questStart(req,res,next);
+ });
+
// Party member accepting the invitation
} else {
if (!group.quest.key) return res.json(400,{err:'No quest invitation has been sent out yet.'});
group.quest.members[user._id] = true;
+ questStart(req,res,next);
}
- questStart(req,res,next);
}
api.questReject = function(req, res, next) {
diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js
index 15263ee0be..639fe18ebf 100644
--- a/website/src/controllers/payments/index.js
+++ b/website/src/controllers/payments/index.js
@@ -76,7 +76,7 @@ exports.createSubscription = function(data, cb) {
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', [
+ utils.txnEmail(data.gift.member, 'gifted-subscription', [
{name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name},
{name: 'X_MONTHS_SUBSCRIPTION', content: months}
]);
@@ -121,7 +121,7 @@ exports.buyGems = function(data, cb) {
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', [
+ 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}
]);
diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js
index 84e7107170..cd004452ed 100644
--- a/website/src/controllers/user.js
+++ b/website/src/controllers/user.js
@@ -459,7 +459,11 @@ api.sessionPartyInvite = function(req,res,next){
.select('invites members').exec(cb);
},
function(group, cb){
- if (!group) return cb("Inviter not in party");
+ if (!group){
+ // Don't send error as it will prevent users from using the site
+ delete req.session.partyInvite;
+ return cb();
+ }
inv.party = req.session.partyInvite;
delete req.session.partyInvite;
if (!~group.invites.indexOf(res.locals.user._id))
diff --git a/website/src/models/user.js b/website/src/models/user.js
index 3030f0c2de..d1445f79e2 100644
--- a/website/src/models/user.js
+++ b/website/src/models/user.js
@@ -308,6 +308,8 @@ var UserSchema = new Schema({
giftedSubscription: {type: Boolean, 'default': true},
invitedParty: {type: Boolean, 'default': true},
invitedGuild: {type: Boolean, 'default': true},
+ questStarted: {type: Boolean, 'default': true},
+ invitedQuest: {type: Boolean, 'default': true},
//remindersToLogin: {type: Boolean, 'default': true},
importantAnnouncements: {type: Boolean, 'default': true}
}
diff --git a/website/views/options/settings.jade b/website/views/options/settings.jade
index a5cee15b45..d1b961fdf9 100644
--- a/website/views/options/settings.jade
+++ b/website/views/options/settings.jade
@@ -305,6 +305,16 @@ script(id='partials/options.settings.notifications.html', type="text/ng-template
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.questStarted', ng-change='set({"preferences.emailNotifications.questStarted": user.preferences.emailNotifications.questStarted ? true: false})')
+ span=env.t('questStarted')
+
+ .checkbox
+ label
+ input(type='checkbox', ng-disabled='user.preferences.emailNotifications.unsubscribeFromAll === true', ng-model='user.preferences.emailNotifications.invitedQuest', ng-change='set({"preferences.emailNotifications.invitedQuest": user.preferences.emailNotifications.invitedQuest ? true: false})')
+ span=env.t('invitedQuest')
+
//.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})')
diff --git a/website/views/shared/new-stuff.jade b/website/views/shared/new-stuff.jade
index 753b36a65c..a88b871f6e 100644
--- a/website/views/shared/new-stuff.jade
+++ b/website/views/shared/new-stuff.jade
@@ -1,16 +1,28 @@
-h5 2/3/2015
+h5 2/8/2015 - EMAIL NOTIFICATIONS AND LOGIN TYPE SWITCHING!
hr
tr
td
- h5 FEBRUARY BACKGROUNDS REVEALED
- .background_distant_castle.pull-right
- p There are three new avatar backgrounds in the Background Shop! Now your avatar can survey a Distant Castle, toil in the Blacksmithy, or explore a Crystal Cave!
- p.small.muted by Holseties, Hanztan, and Twitching
+ h5 Email Notifications
+ p We've implemented email notifications for a variety of events, including receiving a Private Message, being invited to a Party, Guild, or Quest, and receiving a gift of Gems or a Subscription! We've got some more coming up, too, including the much-requested check-in reminders.
+ p Don't want to receive a certain type of notification? No problem! Just go to Notification Settings to tell us exactly which ones you do and do not want to receive. Our messenger dragons will be happy to comply!
+ p.small.muted by paglias and Lemoness
+ tr
+ td
+ h5 Login Type Switching
+ p Want to change your email address, or switch from Facebook login to email login (or vice versa)? Good news! Now you can switch it yourself, under Settings!
+ p.small.muted by Lefnire
hr
a(href='/static/old-news', target='_blank') Read older news
mixin oldNews
+ h5 2/3/2015
+ tr
+ td
+ h5 February Backgrounds Revealed
+ .background_distant_castle.pull-right
+ p There are three new avatar backgrounds in the Background Shop! Now your avatar can survey a Distant Castle, toil in the Blacksmithy, or explore a Crystal Cave!
+ p.small.muted by Holseties, Hanztan, and Twitching
h5 2/2/2015
tr
td