Merge branch 'develop' of github.com:HabitRPG/habitrpg into common-convert

Conflicts:
	src/controllers/groups.js
	src/controllers/payments/index.js
	src/controllers/user.js
	src/models/user.js
This commit is contained in:
Blade Barringer
2015-02-09 22:21:27 -06:00
14 changed files with 2327 additions and 16 deletions

View File

@@ -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 }
);

View File

@@ -8,4 +8,25 @@ db.users.update(
'purchased.plan.planId':'basic_earned',
'purchased.plan.dateTerminated': moment().add('month',1).toDate()
}}
)
)
// 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
// }
// }}}
// )

866
src/controllers/groups.js Normal file
View File

@@ -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;
})
}

View File

@@ -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;

572
src/controllers/user.js Normal file
View File

@@ -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});
}
});
};

533
src/models/user.js Normal file
View File

@@ -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
});

View File

@@ -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'

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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}
]);

View File

@@ -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))

View File

@@ -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}
}

View File

@@ -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})')

View File

@@ -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 <a href='https://habitrpg.com/#/options/profile/backgrounds' target='_blank'>Background Shop</a>! 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 <a href='https://habitrpg.com/#/options/settings/notifications' target='_blank'>Notification Settings</a> 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 <a href='https://habitrpg.com/#/options/settings/settings' target='_blank'>Settings</a>!
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 <a href='https://habitrpg.com/#/options/profile/backgrounds' target='_blank'>Background Shop</a>! 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