Moved folders to website directory
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
var _ = require('lodash');
|
||||
var validator = require('validator');
|
||||
var passport = require('passport');
|
||||
var shared = require('../../common');
|
||||
var async = require('async');
|
||||
var utils = require('../utils');
|
||||
var nconf = require('nconf');
|
||||
var request = require('request');
|
||||
var User = require('../models/user').model;
|
||||
var ga = require('./../utils').ga;
|
||||
var i18n = require('./../i18n');
|
||||
|
||||
var isProd = nconf.get('NODE_ENV') === 'production';
|
||||
|
||||
var api = module.exports;
|
||||
|
||||
var NO_TOKEN_OR_UID = { err: "You must include a token and uid (user id) in your request"};
|
||||
var NO_USER_FOUND = {err: "No user found."};
|
||||
var NO_SESSION_FOUND = { err: "You must be logged in." };
|
||||
var accountSuspended = function(uuid){
|
||||
return {
|
||||
err: 'Account has been suspended, please contact leslie@habitrpg.com with your UUID ('+uuid+') for assistance.',
|
||||
code: 'ACCOUNT_SUSPENDED'
|
||||
};
|
||||
}
|
||||
|
||||
api.auth = function(req, res, next) {
|
||||
var uid = req.headers['x-api-user'];
|
||||
var token = req.headers['x-api-key'];
|
||||
if (!(uid && token)) return res.json(401, NO_TOKEN_OR_UID);
|
||||
User.findOne({_id: uid,apiToken: token}, function(err, user) {
|
||||
if (err) return next(err);
|
||||
if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND);
|
||||
if (user.auth.blocked) return res.json(401, accountSuspended(user._id));
|
||||
|
||||
res.locals.wasModified = req.query._v ? +user._v !== +req.query._v : true;
|
||||
res.locals.user = user;
|
||||
req.session.userId = user._id;
|
||||
return next();
|
||||
});
|
||||
};
|
||||
|
||||
api.authWithSession = function(req, res, next) { //[todo] there is probably a more elegant way of doing this...
|
||||
if (!(req.session && req.session.userId))
|
||||
return res.json(401, NO_SESSION_FOUND);
|
||||
User.findOne({_id: req.session.userId}, function(err, user) {
|
||||
if (err) return next(err);
|
||||
if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND);
|
||||
res.locals.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
api.authWithUrl = function(req, res, next) {
|
||||
User.findOne({_id:req.query._id, apiToken:req.query.apiToken}, function(err,user){
|
||||
if (err) return next(err);
|
||||
if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND);
|
||||
res.locals.user = user;
|
||||
next();
|
||||
})
|
||||
}
|
||||
|
||||
api.registerUser = function(req, res, next) {
|
||||
var confirmPassword = req.body.confirmPassword,
|
||||
email = req.body.email,
|
||||
password = req.body.password,
|
||||
username = req.body.username;
|
||||
if (!(username && password && email)) return res.json(401, {err: ":username, :email, :password, :confirmPassword required"});
|
||||
if (password !== confirmPassword) return res.json(401, {err: ":password and :confirmPassword don't match"});
|
||||
if (!validator.isEmail(email)) return res.json(401, {err: ":email invalid"});
|
||||
async.waterfall([
|
||||
function(cb) {
|
||||
User.findOne({'auth.local.email': email}, cb);
|
||||
},
|
||||
function(found, cb) {
|
||||
if (found) return cb("Email already taken");
|
||||
User.findOne({'auth.local.username': username}, cb);
|
||||
}, function(found, cb) {
|
||||
var newUser, salt, user;
|
||||
if (found) return cb("Username already taken");
|
||||
salt = utils.makeSalt();
|
||||
newUser = {
|
||||
auth: {
|
||||
local: {
|
||||
username: username,
|
||||
email: email,
|
||||
salt: salt,
|
||||
hashed_password: utils.encryptPassword(password, salt)
|
||||
},
|
||||
timestamps: {created: +new Date(), loggedIn: +new Date()}
|
||||
}
|
||||
};
|
||||
newUser.preferences = newUser.preferences || {};
|
||||
newUser.preferences.language = req.language; // User language detected from browser, not saved
|
||||
user = new User(newUser);
|
||||
|
||||
// temporary for conventions
|
||||
if (req.subdomains[0] == 'con') {
|
||||
_.each(user.dailys, function(h){
|
||||
h.repeat = {m:false,t:false,w:false,th:false,f:false,s:false,su:false};
|
||||
})
|
||||
user.extra = {signupEvent: 'wondercon'};
|
||||
}
|
||||
|
||||
user.save(cb);
|
||||
if(isProd) utils.txnEmail({name:username, email:email}, 'welcome');
|
||||
ga.event('register', 'Local').send()
|
||||
}
|
||||
], function(err, saved) {
|
||||
if (err) return res.json(401, {err: err});
|
||||
res.json(200, saved);
|
||||
email = password = username = null;
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Register new user with uname / password
|
||||
*/
|
||||
|
||||
|
||||
api.loginLocal = function(req, res, next) {
|
||||
var username = req.body.username;
|
||||
var password = req.body.password;
|
||||
if (!(username && password)) return res.json(401, {err:'Missing :username or :password in request body, please provide both'});
|
||||
var login = validator.isEmail(username) ? {'auth.local.email':username} : {'auth.local.username':username};
|
||||
User.findOne(login, {auth:1}, function(err, user){
|
||||
if (err) return next(err);
|
||||
if (!user) return res.json(401, {err:"Username or password incorrect. Click 'Forgot Password' for help with either. (Note: usernames are case-sensitive)"});
|
||||
if (user.auth.blocked) return res.json(401, accountSuspended(user._id));
|
||||
// We needed the whole user object first so we can get his salt to encrypt password comparison
|
||||
User.findOne(
|
||||
{$and: [login, {'auth.local.hashed_password': utils.encryptPassword(password, user.auth.local.salt)}]}
|
||||
, {_id:1, apiToken:1}
|
||||
, function(err, user){
|
||||
if (err) return next(err);
|
||||
if (!user) return res.json(401,{err:"Username or password incorrect. Click 'Forgot Password' for help with either. (Note: usernames are case-sensitive)"});
|
||||
res.json({id: user._id,token: user.apiToken});
|
||||
password = null;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
POST /user/auth/social
|
||||
*/
|
||||
api.loginSocial = function(req, res, next) {
|
||||
var access_token = req.body.authResponse.access_token,
|
||||
network = req.body.network;
|
||||
if (network!=='facebook')
|
||||
return res.json(401, {err:"Only Facebook supported currently."});
|
||||
async.auto({
|
||||
profile: function (cb) {
|
||||
passport._strategies[network].userProfile(access_token, cb);
|
||||
},
|
||||
user: ['profile', function (cb, results) {
|
||||
var q = {};
|
||||
q['auth.' + network + '.id'] = results.profile.id;
|
||||
User.findOne(q, {_id: 1, apiToken: 1, auth: 1}, cb);
|
||||
}],
|
||||
register: ['profile', 'user', function (cb, results) {
|
||||
if (results.user) return cb(null, results.user);
|
||||
// Create new user
|
||||
var prof = results.profile;
|
||||
var user = {
|
||||
preferences: {
|
||||
language: req.language // User language detected from browser, not saved
|
||||
},
|
||||
auth: {
|
||||
timestamps: {created: +new Date(), loggedIn: +new Date()}
|
||||
}
|
||||
};
|
||||
user.auth[network] = prof;
|
||||
user = new User(user);
|
||||
user.save(cb);
|
||||
|
||||
if (isProd && prof.emails && prof.emails[0] && prof.emails[0].value) {
|
||||
utils.txnEmail({name: prof.displayName || prof.username, email: prof.emails[0].value}, 'welcome');
|
||||
}
|
||||
ga.event('register', network).send();
|
||||
}]
|
||||
}, function(err, results){
|
||||
if (err) return res.json(401, {err: err.toString ? err.toString() : err});
|
||||
var acct = results.register[0] ? results.register[0] : results.register;
|
||||
if (acct.auth.blocked) return res.json(401, accountSuspended(acct._id));
|
||||
return res.json(200, {id:acct._id, token:acct.apiToken});
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /user/auth/social
|
||||
* TODO implement
|
||||
*/
|
||||
api.deleteSocial = function(req,res,next){next()}
|
||||
|
||||
api.resetPassword = function(req, res, next){
|
||||
var email = req.body.email,
|
||||
salt = utils.makeSalt(),
|
||||
newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later)
|
||||
hashed_password = utils.encryptPassword(newPassword, salt);
|
||||
|
||||
// escape email for regex, then search case-insensitive. See http://stackoverflow.com/a/3561711/362790
|
||||
var emailRegExp = new RegExp('^' + email.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '$', 'i');
|
||||
User.findOne({'auth.local.email':emailRegExp}, function(err, user){
|
||||
if (err) return next(err);
|
||||
if (!user) return res.send(500, {err:"Couldn't find a user registered for email " + email});
|
||||
user.auth.local.salt = salt;
|
||||
user.auth.local.hashed_password = hashed_password;
|
||||
utils.txnEmail(user, 'reset-password', [
|
||||
{name: "NEW_PASSWORD", content: newPassword},
|
||||
{name: "USERNAME", content: user.auth.local.username}
|
||||
]);
|
||||
user.save(function(err){
|
||||
if(err) return next(err);
|
||||
res.send('New password sent to '+ email);
|
||||
email = salt = newPassword = hashed_password = null;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
api.changeUsername = function(req, res, next) {
|
||||
var user = res.locals.user,
|
||||
password = req.body.password,
|
||||
newUsername = req.body.newUsername;
|
||||
|
||||
User.findOne({'auth.local.username': newUsername}, function(err, result) {
|
||||
if (err) next(err);
|
||||
if(result) return res.json(401, {err: "Username already taken"});
|
||||
|
||||
var salt = user.auth.local.salt;
|
||||
var hashed_password = utils.encryptPassword(password, salt);
|
||||
|
||||
if (hashed_password !== user.auth.local.hashed_password)
|
||||
return res.json(401, {err:"Incorrect password"});
|
||||
|
||||
user.auth.local.username = newUsername;
|
||||
user.save(function(err, saved){
|
||||
if (err) next(err);
|
||||
res.send(200);
|
||||
user = password = newUsername = null;
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
api.changePassword = function(req, res, next) {
|
||||
var user = res.locals.user,
|
||||
oldPassword = req.body.oldPassword,
|
||||
newPassword = req.body.newPassword,
|
||||
confirmNewPassword = req.body.confirmNewPassword;
|
||||
|
||||
if (newPassword != confirmNewPassword)
|
||||
return res.json(401, {err: "Password & Confirm don't match"});
|
||||
|
||||
var salt = user.auth.local.salt,
|
||||
hashed_old_password = utils.encryptPassword(oldPassword, salt),
|
||||
hashed_new_password = utils.encryptPassword(newPassword, salt);
|
||||
|
||||
if (hashed_old_password !== user.auth.local.hashed_password)
|
||||
return res.json(401, {err:"Old password doesn't match"});
|
||||
|
||||
user.auth.local.hashed_password = hashed_new_password;
|
||||
user.save(function(err, saved){
|
||||
if (err) next(err);
|
||||
res.send(200);
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
Registers a new user. Only accepting username/password registrations, no Facebook
|
||||
*/
|
||||
|
||||
api.setupPassport = function(router) {
|
||||
|
||||
router.get('/logout', i18n.getUserLanguage, function(req, res) {
|
||||
req.logout();
|
||||
delete req.session.userId;
|
||||
res.redirect('/');
|
||||
})
|
||||
|
||||
};
|
||||
@@ -0,0 +1,425 @@
|
||||
// @see ../routes for routing
|
||||
|
||||
var _ = require('lodash');
|
||||
var nconf = require('nconf');
|
||||
var async = require('async');
|
||||
var shared = require('../../common');
|
||||
var User = require('./../models/user').model;
|
||||
var Group = require('./../models/group').model;
|
||||
var Challenge = require('./../models/challenge').model;
|
||||
var logging = require('./../logging');
|
||||
var csv = require('express-csv');
|
||||
var api = module.exports;
|
||||
|
||||
|
||||
/*
|
||||
------------------------------------------------------------------------
|
||||
Challenges
|
||||
------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
api.list = function(req, res, next) {
|
||||
var user = res.locals.user;
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
// Get all available groups I belong to
|
||||
Group.find({members: {$in: [user._id]}}).select('_id').exec(cb);
|
||||
},
|
||||
function(gids, cb){
|
||||
// and their challenges
|
||||
Challenge.find({
|
||||
$or:[
|
||||
{leader: user._id},
|
||||
{members:{$in:[user._id]}}, // all challenges I belong to (is this necessary? thought is a left a group, but not its challenge)
|
||||
{group:{$in:gids}}, // all challenges in my groups
|
||||
{group: 'habitrpg'} // public group
|
||||
],
|
||||
_id:{$ne:'95533e05-1ff9-4e46-970b-d77219f199e9'} // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug
|
||||
})
|
||||
.select('name leader description group memberCount prize official')
|
||||
.select({members:{$elemMatch:{$in:[user._id]}}})
|
||||
.sort('-official -timestamp')
|
||||
.populate('group', '_id name')
|
||||
.populate('leader', 'profile.name')
|
||||
.exec(cb);
|
||||
}
|
||||
], function(err, challenges){
|
||||
if (err) return next(err);
|
||||
_.each(challenges, function(c){
|
||||
c._isMember = c.members.length > 0;
|
||||
})
|
||||
res.json(challenges);
|
||||
user = null;
|
||||
});
|
||||
}
|
||||
|
||||
// GET
|
||||
api.get = function(req, res, next) {
|
||||
// TODO use mapReduce() or aggregate() here to
|
||||
// 1) Find the sum of users.tasks.values within the challnege (eg, {'profile.name':'tyler', 'sum': 100})
|
||||
// 2) Sort by the sum
|
||||
// 3) Limit 30 (only show the 30 users currently in the lead)
|
||||
Challenge.findById(req.params.cid)
|
||||
.populate('members', 'profile.name _id')
|
||||
.exec(function(err, challenge){
|
||||
if(err) return next(err);
|
||||
if (!challenge) return res.json(404, {err: 'Challenge ' + req.params.cid + ' not found'});
|
||||
res.json(challenge);
|
||||
})
|
||||
}
|
||||
|
||||
api.csv = function(req, res, next) {
|
||||
var cid = req.params.cid;
|
||||
var challenge;
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
Challenge.findById(cid,cb)
|
||||
},
|
||||
function(_challenge,cb) {
|
||||
challenge = _challenge;
|
||||
if (!challenge) return cb('Challenge ' + cid + ' not found');
|
||||
User.aggregate([
|
||||
{$match:{'_id':{ '$in': challenge.members}}}, //yes, we want members
|
||||
{$project:{'profile.name':1,tasks:{$setUnion:["$habits","$dailys","$todos","$rewards"]}}},
|
||||
{$unwind:"$tasks"},
|
||||
{$match:{"tasks.challenge.id":cid}},
|
||||
{$sort:{'tasks.type':1,'tasks.id':1}},
|
||||
{$group:{_id:"$_id", "tasks":{$push:"$tasks"},"name":{$first:"$profile.name"}}}
|
||||
], cb);
|
||||
}
|
||||
],function(err,users){
|
||||
if(err) return next(err);
|
||||
var output = ['UUID','name'];
|
||||
_.each(challenge.tasks,function(t){
|
||||
//output.push(t.type+':'+t.text);
|
||||
//not the right order yet
|
||||
output.push('Task');
|
||||
output.push('Value');
|
||||
output.push('Notes');
|
||||
})
|
||||
output = [output];
|
||||
_.each(users, function(u){
|
||||
var uData = [u._id,u.name];
|
||||
_.each(u.tasks,function(t){
|
||||
uData = uData.concat([t.type+':'+t.text, t.value, t.notes]);
|
||||
})
|
||||
output.push(uData);
|
||||
});
|
||||
res.header('Content-disposition', 'attachment; filename='+cid+'.csv');
|
||||
res.csv(output);
|
||||
challenge = cid = null;
|
||||
})
|
||||
}
|
||||
|
||||
api.getMember = function(req, res, next) {
|
||||
var cid = req.params.cid;
|
||||
var uid = req.params.uid;
|
||||
|
||||
// We need to start using the aggregation framework instead of in-app filtering, see http://docs.mongodb.org/manual/aggregation/
|
||||
// See code at 32c0e75 for unwind/group example
|
||||
|
||||
//http://stackoverflow.com/questions/24027213/how-to-match-multiple-array-elements-without-using-unwind
|
||||
var proj = {'profile.name':'$profile.name'};
|
||||
_.each(['habits','dailys','todos','rewards'], function(type){
|
||||
proj[type] = {
|
||||
$setDifference: [{
|
||||
$map: {
|
||||
input: '$'+type,
|
||||
as: "el",
|
||||
in: {
|
||||
$cond: [{$eq: ["$$el.challenge.id", cid]}, '$$el', false]
|
||||
}
|
||||
}
|
||||
}, [false]]
|
||||
}
|
||||
});
|
||||
User.aggregate()
|
||||
.match({_id: uid})
|
||||
.project(proj)
|
||||
.exec(function(err, member){
|
||||
if (err) return next(err);
|
||||
if (!member) return res.json(404, {err: 'Member '+uid+' for challenge '+cid+' not found'});
|
||||
res.json(member[0]);
|
||||
uid = cid = null;
|
||||
});
|
||||
}
|
||||
|
||||
// CREATE
|
||||
api.create = function(req, res, next){
|
||||
var user = res.locals.user;
|
||||
|
||||
async.auto({
|
||||
get_group: function(cb){
|
||||
var q = {_id:req.body.group};
|
||||
if (req.body.group!='habitrpg') q.members = {$in:[user._id]}; // make sure they're a member of the group
|
||||
Group.findOne(q, cb);
|
||||
},
|
||||
save_chal: ['get_group', function(cb, results){
|
||||
var group = results.get_group,
|
||||
prize = +req.body.prize;
|
||||
if (!group)
|
||||
return cb({code:404, err:"Group." + req.body.group + " not found"});
|
||||
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id)
|
||||
return cb({code:401, err: "Only the group leader can create challenges"});
|
||||
// If they're adding a prize, do some validation
|
||||
if (prize < 0)
|
||||
return cb({code:401, err: 'Challenge prize must be >= 0'});
|
||||
if (req.body.group=='habitrpg' && prize < 1)
|
||||
return cb({code:401, err: 'Prize must be at least 1 Gem for public challenges.'});
|
||||
if (prize > 0) {
|
||||
var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0);
|
||||
var prizeCost = prize/4; // I really should have stored user.balance as gems rather than dollars... stupid...
|
||||
if (prizeCost > user.balance + groupBalance)
|
||||
return cb("You can't afford this prize. Purchase more gems or lower the prize amount.")
|
||||
|
||||
if (groupBalance >= prizeCost) {
|
||||
// Group pays for all of prize
|
||||
group.balance -= prizeCost;
|
||||
} else if (groupBalance > 0) {
|
||||
// User pays remainder of prize cost after group
|
||||
var remainder = prizeCost - group.balance;
|
||||
group.balance = 0;
|
||||
user.balance -= remainder;
|
||||
} else {
|
||||
// User pays for all of prize
|
||||
user.balance -= prizeCost;
|
||||
}
|
||||
}
|
||||
req.body.leader = user._id;
|
||||
req.body.official = user.contributor.admin && req.body.official;
|
||||
var chal = new Challenge(req.body); // FIXME sanitize
|
||||
chal.members.push(user._id);
|
||||
chal.save(cb);
|
||||
}],
|
||||
save_group: ['save_chal', function(cb, results){
|
||||
results.get_group.challenges.push(results.save_chal[0]._id);
|
||||
results.get_group.save(cb);
|
||||
}],
|
||||
sync_user: ['save_group', function(cb, results){
|
||||
// Auto-join creator to challenge (see members.push above)
|
||||
results.save_chal[0].syncToUser(user, cb);
|
||||
}]
|
||||
}, function(err, results){
|
||||
if (err) return err.code? res.json(err.code, err) : next(err);
|
||||
return res.json(results.save_chal[0]);
|
||||
user = null;
|
||||
})
|
||||
}
|
||||
|
||||
// UPDATE
|
||||
api.update = function(req, res, next){
|
||||
var cid = req.params.cid;
|
||||
var user = res.locals.user;
|
||||
var before;
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
// We first need the original challenge data, since we're going to compare against new & decide to sync users
|
||||
Challenge.findById(cid, cb);
|
||||
},
|
||||
function(_before, cb) {
|
||||
if (!_before) return cb('Challenge ' + cid + ' not found');
|
||||
if (_before.leader != user._id) return cb("You don't have permissions to edit this challenge");
|
||||
// Update the challenge, since syncing will need the updated challenge. But store `before` we're going to do some
|
||||
// before-save / after-save comparison to determine if we need to sync to users
|
||||
before = _before;
|
||||
var attrs = _.pick(req.body, 'name shortName description habits dailys todos rewards date'.split(' '));
|
||||
Challenge.findByIdAndUpdate(cid, {$set:attrs}, cb);
|
||||
},
|
||||
function(saved, cb) {
|
||||
|
||||
// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers
|
||||
if (before.isOutdated(req.body)) {
|
||||
User.find({_id: {$in: saved.members}}, function(err, users){
|
||||
logging.info('Challenge updated, sync to subscribers');
|
||||
if (err) throw err;
|
||||
_.each(users, function(user){
|
||||
saved.syncToUser(user);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// after saving, we're done as far as the client's concerned. We kick off syncing (heavy task) in the background
|
||||
cb(null, saved);
|
||||
}
|
||||
], function(err, saved){
|
||||
if(err) next(err);
|
||||
res.json(saved);
|
||||
cid = user = before = null;
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by either delete() or selectWinner(). Will delete the challenge and set the "broken" property on all users' subscribed tasks
|
||||
* @param {cid} the challenge id
|
||||
* @param {broken} the object representing the broken status of the challenge. Eg:
|
||||
* {broken: 'CHALLENGE_DELETED', id: CHALLENGE_ID}
|
||||
* {broken: 'CHALLENGE_CLOSED', id: CHALLENGE_ID, winner: USER_NAME}
|
||||
*/
|
||||
function closeChal(cid, broken, cb) {
|
||||
var removed;
|
||||
async.waterfall([
|
||||
function(cb2){
|
||||
Challenge.findOneAndRemove({_id:cid}, cb2)
|
||||
},
|
||||
function(_removed, cb2) {
|
||||
removed = _removed;
|
||||
var pull = {'$pull':{}}; pull['$pull'][_removed._id] = 1;
|
||||
Group.findByIdAndUpdate(_removed.group, pull);
|
||||
User.find({_id:{$in: removed.members}}, cb2);
|
||||
},
|
||||
function(users, cb2) {
|
||||
var parallel = [];
|
||||
_.each(users, function(user){
|
||||
var tag = _.find(user.tags, {id:cid});
|
||||
if (tag) tag.challenge = undefined;
|
||||
_.each(user.tasks, function(task){
|
||||
if (task.challenge && task.challenge.id == removed._id) {
|
||||
_.merge(task.challenge, broken);
|
||||
}
|
||||
})
|
||||
parallel.push(function(cb3){
|
||||
user.save(cb3);
|
||||
})
|
||||
})
|
||||
async.parallel(parallel, cb2);
|
||||
removed = null;
|
||||
}
|
||||
], cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete & close
|
||||
*/
|
||||
api['delete'] = function(req, res, next){
|
||||
var user = res.locals.user;
|
||||
var cid = req.params.cid;
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
Challenge.findById(cid, cb);
|
||||
},
|
||||
function(chal, cb){
|
||||
if (!chal) return cb('Challenge ' + cid + ' not found');
|
||||
if (chal.leader != user._id) return cb("You don't have permissions to edit this challenge");
|
||||
closeChal(req.params.cid, {broken: 'CHALLENGE_DELETED'}, cb);
|
||||
}
|
||||
], function(err){
|
||||
if (err) return next(err);
|
||||
res.send(200);
|
||||
user = cid = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select Winner & Close
|
||||
*/
|
||||
api.selectWinner = function(req, res, next) {
|
||||
if (!req.query.uid) return res.json(401, {err: 'Must select a winner'});
|
||||
var user = res.locals.user;
|
||||
var cid = req.params.cid;
|
||||
var chal;
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
Challenge.findById(cid, cb);
|
||||
},
|
||||
function(_chal, cb){
|
||||
chal = _chal;
|
||||
if (!chal) return cb('Challenge ' + cid + ' not found');
|
||||
if (chal.leader != user._id) return cb("You don't have permissions to edit this challenge");
|
||||
User.findById(req.query.uid, cb)
|
||||
},
|
||||
function(winner, cb){
|
||||
if (!winner) return cb('Winner ' + req.query.uid + ' not found.');
|
||||
_.defaults(winner.achievements, {challenges: []});
|
||||
winner.achievements.challenges.push(chal.name);
|
||||
winner.balance += chal.prize/4;
|
||||
winner.save(cb);
|
||||
},
|
||||
function(saved, num, cb) {
|
||||
closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb);
|
||||
}
|
||||
], function(err){
|
||||
if (err) return next(err);
|
||||
res.send(200);
|
||||
user = cid = chal = null;
|
||||
})
|
||||
}
|
||||
|
||||
api.join = function(req, res, next){
|
||||
var user = res.locals.user;
|
||||
var cid = req.params.cid;
|
||||
|
||||
async.waterfall([
|
||||
function(cb) {
|
||||
Challenge.findByIdAndUpdate(cid, {$addToSet:{members:user._id}}, cb);
|
||||
},
|
||||
function(chal, cb) {
|
||||
|
||||
// Trigger updating challenge member count in the background. We can't do it above because we don't have
|
||||
// _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above.
|
||||
Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec();
|
||||
|
||||
if (!~user.challenges.indexOf(cid))
|
||||
user.challenges.unshift(cid);
|
||||
// Add all challenge's tasks to user's tasks
|
||||
chal.syncToUser(user, function(err){
|
||||
if (err) return cb(err);
|
||||
cb(null, chal); // we want the saved challenge in the return results, due to ng-resource
|
||||
});
|
||||
}
|
||||
], function(err, chal){
|
||||
if(err) return next(err);
|
||||
chal._isMember = true;
|
||||
res.json(chal);
|
||||
user = cid = null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
api.leave = function(req, res, next){
|
||||
var user = res.locals.user;
|
||||
var cid = req.params.cid;
|
||||
// whether or not to keep challenge's tasks. strictly default to true if "keep-all" isn't provided
|
||||
var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all';
|
||||
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
Challenge.findByIdAndUpdate(cid, {$pull:{members:user._id}}, cb);
|
||||
},
|
||||
function(chal, cb){
|
||||
|
||||
// Trigger updating challenge member count in the background. We can't do it above because we don't have
|
||||
// _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above.
|
||||
if (chal)
|
||||
Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec();
|
||||
|
||||
var i = user.challenges.indexOf(cid)
|
||||
if (~i) user.challenges.splice(i,1);
|
||||
user.unlink({cid:cid, keep:keep}, function(err){
|
||||
if (err) return cb(err);
|
||||
cb(null, chal);
|
||||
})
|
||||
}
|
||||
], function(err, chal){
|
||||
if(err) return next(err);
|
||||
if (chal) chal._isMember = false;
|
||||
res.json(chal);
|
||||
user = cid = keep = null;
|
||||
});
|
||||
}
|
||||
|
||||
api.unlink = function(req, res, next) {
|
||||
// they're scoring the task - commented out, we probably don't need it due to route ordering in api.js
|
||||
//var urlParts = req.originalUrl.split('/');
|
||||
//if (_.contains(['up','down'], urlParts[urlParts.length -1])) return next();
|
||||
|
||||
var user = res.locals.user;
|
||||
var tid = req.params.id;
|
||||
var cid = user.tasks[tid].challenge.id;
|
||||
if (!req.query.keep)
|
||||
return res.json(400, {err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'});
|
||||
user.unlink({cid:cid, keep:req.query.keep, tid:tid}, function(err, saved){
|
||||
if (err) return next(err);
|
||||
res.send(200);
|
||||
user = tid = cid = null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
var _ = require('lodash');
|
||||
var Coupon = require('./../models/coupon').model;
|
||||
var api = module.exports;
|
||||
var csv = require('express-csv');
|
||||
var async = require('async');
|
||||
|
||||
api.ensureAdmin = function(req, res, next) {
|
||||
if (!res.locals.user.contributor.sudo) return res.json(401, {err:"You don't have admin access"});
|
||||
next();
|
||||
}
|
||||
|
||||
api.generateCoupons = function(req,res,next) {
|
||||
Coupon.generate(req.params.event, req.query.count, function(err){
|
||||
if(err) return next(err);
|
||||
res.send(200);
|
||||
});
|
||||
}
|
||||
|
||||
api.getCoupons = function(req,res,next) {
|
||||
var options = {sort:'seq'};
|
||||
if (req.query.limit) options.limit = req.query.limit;
|
||||
if (req.query.skip) options.skip = req.query.skip;
|
||||
Coupon.find({},{}, options, function(err,coupons){
|
||||
//res.header('Content-disposition', 'attachment; filename=coupons.csv');
|
||||
res.csv([['code']].concat(_.map(coupons, function(c){
|
||||
return [c._id];
|
||||
})));
|
||||
});
|
||||
}
|
||||
|
||||
api.enterCode = function(req,res,next) {
|
||||
Coupon.apply(res.locals.user,req.params.code,function(err,user){
|
||||
if (err) return res.json(400,{err:err});
|
||||
res.json(user);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
var _ = require('lodash');
|
||||
var csv = require('express-csv');
|
||||
var express = require('express');
|
||||
var nconf = require('nconf');
|
||||
var moment = require('moment');
|
||||
var dataexport = module.exports;
|
||||
var js2xmlparser = require("js2xmlparser");
|
||||
var pd = require('pretty-data').pd;
|
||||
var User = require('../models/user').model;
|
||||
|
||||
// Avatar screenshot/static-page includes
|
||||
var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres
|
||||
var AWS = require('aws-sdk');
|
||||
AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")});
|
||||
var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream
|
||||
var bucket = nconf.get("S3:bucket");
|
||||
var request = require('request');
|
||||
|
||||
/*
|
||||
------------------------------------------------------------------------
|
||||
Data export
|
||||
------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
dataexport.history = function(req, res) {
|
||||
var user = res.locals.user;
|
||||
var output = [
|
||||
["Task Name", "Task ID", "Task Type", "Date", "Value"]
|
||||
];
|
||||
_.each(user.tasks, function(task) {
|
||||
_.each(task.history, function(history) {
|
||||
output.push(
|
||||
[task.text, task.id, task.type, moment(history.date).format("MM-DD-YYYY HH:mm:ss"), history.value]
|
||||
);
|
||||
});
|
||||
});
|
||||
return res.csv(output);
|
||||
}
|
||||
|
||||
var userdata = function(user) {
|
||||
if(user.auth && user.auth.local) {
|
||||
delete user.auth.local.salt;
|
||||
delete user.auth.local.hashed_password;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
dataexport.leanuser = function(req, res, next) {
|
||||
User.findOne({_id: res.locals.user._id}).lean().exec(function(err, user) {
|
||||
if (err) return res.json(500, {err: err});
|
||||
if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND);
|
||||
res.locals.user = user;
|
||||
return next();
|
||||
});
|
||||
};
|
||||
|
||||
dataexport.userdata = {
|
||||
xml: function(req, res) {
|
||||
var user = userdata(res.locals.user);
|
||||
return res.xml({data: JSON.stringify(user), rootname: 'user'});
|
||||
},
|
||||
json: function(req, res) {
|
||||
var user = userdata(res.locals.user);
|
||||
return res.jsonstring(user);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
------------------------------------------------------------------------
|
||||
Express Extensions (should be refactored into a module)
|
||||
------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
var expressres = express.response || http.ServerResponse.prototype;
|
||||
|
||||
expressres.xml = function(obj, headers, status) {
|
||||
var body = '';
|
||||
this.charset = this.charset || 'utf-8';
|
||||
this.header('Content-Type', 'text/xml');
|
||||
this.header('Content-Disposition', 'attachment');
|
||||
body = pd.xml(js2xmlparser(obj.rootname,obj.data));
|
||||
return this.send(body, headers, status);
|
||||
};
|
||||
|
||||
expressres.jsonstring = function(obj, headers, status) {
|
||||
var body = '';
|
||||
this.charset = this.charset || 'utf-8';
|
||||
this.header('Content-Type', 'application/json');
|
||||
this.header('Content-Disposition', 'attachment');
|
||||
body = pd.json(JSON.stringify(obj));
|
||||
return this.send(body, headers, status);
|
||||
};
|
||||
|
||||
/*
|
||||
------------------------------------------------------------------------
|
||||
Static page and image screenshot of avatar
|
||||
------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
|
||||
dataexport.avatarPage = function(req, res) {
|
||||
User.findById(req.params.uuid).select('stats profile items achievements preferences backer contributor').exec(function(err, user){
|
||||
res.render('avatar-static', {
|
||||
title: user.profile.name,
|
||||
env: _.defaults({user:user},res.locals.habitrpg)
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
dataexport.avatarImage = function(req, res, next) {
|
||||
var filename = 'avatar-'+req.params.uuid+'.png';
|
||||
request.head('https://'+bucket+'.s3.amazonaws.com/'+filename, function(err,response,body) {
|
||||
// cache images for 10 minutes on aws, else upload a new one
|
||||
if (response.statusCode==200 && moment().diff(response.headers['last-modified'], 'minutes') < 10)
|
||||
return res.redirect(301, 'https://' + bucket + '.s3.amazonaws.com/' + filename);
|
||||
new Pageres()//{delay:1}
|
||||
.src(nconf.get('BASE_URL') + '/export/avatar-' + req.params.uuid + '.html', ['140x147'], {crop: true, filename: filename.replace('.png', '')})
|
||||
.run(function (err, file) {
|
||||
if (err) return next(err);
|
||||
// see http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#createMultipartUpload-property
|
||||
var upload = s3Stream.upload({
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
ACL: "public-read",
|
||||
StorageClass: "REDUCED_REDUNDANCY",
|
||||
ContentType: "image/png",
|
||||
Expires: +moment().add({minutes: 3})
|
||||
});
|
||||
upload.on('error', function (err) {
|
||||
next(err);
|
||||
});
|
||||
upload.on('uploaded', function (details) {
|
||||
res.redirect(details.Location);
|
||||
});
|
||||
file[0].pipe(upload);
|
||||
});
|
||||
})
|
||||
};
|
||||
@@ -0,0 +1,804 @@
|
||||
// @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('../../common');
|
||||
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
|
||||
*/
|
||||
var populateQuery = function(type, q){
|
||||
if (type == 'party')
|
||||
q.populate('members', partyFields);
|
||||
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);
|
||||
if (isProd){
|
||||
|
||||
var addressesToSendTo = JSON.parse(nconf.get('FLAG_REPORT_EMAIL'));
|
||||
|
||||
if(Array.isArray(addressesToSendTo)){
|
||||
addressesToSendTo = addressesToSendTo.map(function(email){
|
||||
return {email: email}
|
||||
});
|
||||
}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);
|
||||
|
||||
// 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){
|
||||
populateQuery(group.type, Group.findById(group._id)).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;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// 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;
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
var _ = require('lodash');
|
||||
var nconf = require('nconf');
|
||||
var async = require('async');
|
||||
var shared = require('../../common');
|
||||
var User = require('./../models/user').model;
|
||||
var Group = require('./../models/group').model;
|
||||
var api = module.exports;
|
||||
|
||||
api.ensureAdmin = function(req, res, next) {
|
||||
var user = res.locals.user;
|
||||
if (!(user.contributor && user.contributor.admin)) return res.json(401, {err:"You don't have admin access"});
|
||||
next();
|
||||
}
|
||||
|
||||
api.getHeroes = function(req,res,next) {
|
||||
User.find({'contributor.level':{$gt:0}})
|
||||
.select('contributor backer balance profile.name')
|
||||
.sort('-contributor.level')
|
||||
.exec(function(err, users){
|
||||
if (err) return next(err);
|
||||
res.json(users);
|
||||
});
|
||||
}
|
||||
|
||||
api.getPatrons = function(req,res,next){
|
||||
var page = req.query.page || 0,
|
||||
perPage = 50;
|
||||
User.find({'backer.tier':{$gt:0}})
|
||||
.select('contributor backer profile.name')
|
||||
.sort('-backer.tier')
|
||||
.skip(page*perPage)
|
||||
.limit(perPage)
|
||||
.exec(function(err, users){
|
||||
if (err) return next(err);
|
||||
res.json(users);
|
||||
});
|
||||
}
|
||||
|
||||
api.getHero = function(req,res,next) {
|
||||
User.findById(req.params.uid)
|
||||
.select('contributor balance profile.name purchased items')
|
||||
.select('auth.local.username auth.local.email auth.facebook auth.blocked')
|
||||
.exec(function(err, user){
|
||||
if (err) return next(err)
|
||||
if (!user) return res.json(400,{err:'User not found'});
|
||||
res.json(user);
|
||||
});
|
||||
}
|
||||
|
||||
api.updateHero = function(req,res,next) {
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
User.findById(req.params.uid, cb);
|
||||
},
|
||||
function(member, cb){
|
||||
if (!member) return res.json(404, {err: "User not found"});
|
||||
member.balance = req.body.balance || 0;
|
||||
var newTier = req.body.contributor.level; // tier = level in this context
|
||||
var oldTier = member.contributor && member.contributor.level || 0;
|
||||
if (newTier > oldTier) {
|
||||
member.flags.contributor = true;
|
||||
var gemsPerTier = {1:3, 2:3, 3:3, 4:4, 5:4, 6:4, 7:4, 8:0, 9:0}; // e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff
|
||||
var tierDiff = newTier - oldTier; // can be 2+ tier increases at once
|
||||
while (tierDiff) {
|
||||
member.balance += gemsPerTier[newTier] / 4; // balance is in $
|
||||
tierDiff--;
|
||||
newTier--; // give them gems for the next tier down if they weren't aready that tier
|
||||
}
|
||||
}
|
||||
member.contributor = req.body.contributor;
|
||||
member.purchased.ads = req.body.purchased.ads;
|
||||
if (member.contributor.level >= 6) member.items.pets['Dragon-Hydra'] = 5;
|
||||
if (req.body.itemPath && req.body.itemVal
|
||||
&& req.body.itemPath.indexOf('items.') === 0
|
||||
&& User.schema.paths[req.body.itemPath]) {
|
||||
shared.dotSet(member, req.body.itemPath, req.body.itemVal); // Sanitization at 5c30944 (deemed unnecessary)
|
||||
}
|
||||
if (_.isBoolean(req.body.auth.blocked)) member.auth.blocked = req.body.auth.blocked;
|
||||
member.save(cb);
|
||||
}
|
||||
], function(err, saved){
|
||||
if (err) return next(err);
|
||||
res.json(204);
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
var User = require('mongoose').model('User');
|
||||
var groups = require('../models/group');
|
||||
var partyFields = require('./groups').partyFields
|
||||
var api = module.exports;
|
||||
var async = require('async');
|
||||
var _ = require('lodash');
|
||||
var shared = require('../../common');
|
||||
|
||||
var fetchMember = function(uuid, restrict){
|
||||
return function(cb){
|
||||
var q = User.findById(uuid);
|
||||
if (restrict) q.select(partyFields);
|
||||
q.exec(function(err, member){
|
||||
if (err) return cb(err);
|
||||
if (!member) return cb({code:404, err: 'User not found'});
|
||||
return cb(null, member);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var sendErr = function(err, res, next){
|
||||
err.code ? res.json(err.code, {err: err.err}) : next(err);
|
||||
}
|
||||
|
||||
api.getMember = function(req, res, next) {
|
||||
fetchMember(req.params.uuid, true)(function(err, member){
|
||||
if (err) return sendErr(err, res, next);
|
||||
res.json(member);
|
||||
})
|
||||
}
|
||||
|
||||
api.sendMessage = function(user, member, data){
|
||||
var msg;
|
||||
if (!data.type) {
|
||||
msg = data.message
|
||||
} else {
|
||||
msg = "`Hello " + member.profile.name + ", " + user.profile.name + " has sent you ";
|
||||
msg += (data.type=='gems') ? data.gems.amount + " gems!`" : shared.content.subscriptionBlocks[data.subscription.key].months + " months of subscription!`";
|
||||
msg += data.message;
|
||||
}
|
||||
shared.refPush(member.inbox.messages, groups.chatDefaults(msg, user));
|
||||
member.inbox.newMessages++;
|
||||
member._v++;
|
||||
member.markModified('inbox.messages');
|
||||
|
||||
shared.refPush(user.inbox.messages, _.defaults({sent:true}, groups.chatDefaults(msg, member)));
|
||||
user.markModified('inbox.messages');
|
||||
}
|
||||
|
||||
api.sendPrivateMessage = function(req, res, next){
|
||||
async.waterfall([
|
||||
fetchMember(req.params.uuid),
|
||||
function(member, cb) {
|
||||
if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me
|
||||
|| ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them
|
||||
|| member.inbox.optOut) { // or if they've opted out of messaging
|
||||
return cb({code: 401, err: "Can't send message to this user."});
|
||||
}
|
||||
api.sendMessage(res.locals.user, member, {message:req.body.message});
|
||||
async.parallel([
|
||||
function (cb2) { member.save(cb2) },
|
||||
function (cb2) { res.locals.user.save(cb2) }
|
||||
], cb);
|
||||
}
|
||||
], function(err){
|
||||
if (err) return sendErr(err, res, next);
|
||||
res.send(200);
|
||||
})
|
||||
}
|
||||
|
||||
api.sendGift = function(req, res, next){
|
||||
async.waterfall([
|
||||
fetchMember(req.params.uuid),
|
||||
function(member, cb) {
|
||||
// Gems
|
||||
switch (req.body.type) {
|
||||
case "gems":
|
||||
var amt = req.body.gems.amount / 4,
|
||||
user = res.locals.user;
|
||||
if (member.id == user.id)
|
||||
return cb({code: 401, err: "Cannot send gems to yourself. Try a subscription instead."});
|
||||
if (!amt || amt <=0 || user.balance < amt)
|
||||
return cb({code: 401, err: "Amount must be within 0 and your current number of gems."});
|
||||
member.balance += amt;
|
||||
user.balance -= amt;
|
||||
api.sendMessage(user, member, req.body);
|
||||
return async.parallel([
|
||||
function (cb2) { member.save(cb2) },
|
||||
function (cb2) { user.save(cb2) }
|
||||
], cb);
|
||||
case "subscription":
|
||||
return cb();
|
||||
default:
|
||||
return cb({code:400, err:"Body must contain a gems:{amount,fromBalance} or subscription:{months} object"});
|
||||
}
|
||||
}
|
||||
], function(err) {
|
||||
if (err) return sendErr(err, res, next);
|
||||
res.send(200);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
var iap = require('in-app-purchase');
|
||||
var async = require('async');
|
||||
var payments = require('./index');
|
||||
var nconf = require('nconf');
|
||||
|
||||
var inAppPurchase = require('in-app-purchase');
|
||||
inAppPurchase.config({
|
||||
// this is the path to the directory containing iap-sanbox/iap-live files
|
||||
googlePublicKeyPath: nconf.get("IAP_GOOGLE_KEYDIR")
|
||||
});
|
||||
|
||||
// Validation ERROR Codes
|
||||
var INVALID_PAYLOAD = 6778001;
|
||||
var CONNECTION_FAILED = 6778002;
|
||||
var PURCHASE_EXPIRED = 6778003;
|
||||
|
||||
exports.androidVerify = function(req, res, next) {
|
||||
var iapBody = req.body;
|
||||
var user = res.locals.user;
|
||||
|
||||
iap.setup(function (error) {
|
||||
if (error) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error'
|
||||
};
|
||||
|
||||
console.error('IAP Setup ERROR');
|
||||
console.error(error);
|
||||
|
||||
res.json(resObj);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
google receipt must be provided as an object
|
||||
{
|
||||
"data": "{stringified data object}",
|
||||
"signature": "signature from google"
|
||||
}
|
||||
*/
|
||||
var testObj = {
|
||||
data: iapBody.transaction.receipt,
|
||||
signature: iapBody.transaction.signature
|
||||
};
|
||||
|
||||
// iap is ready
|
||||
iap.validate(iap.GOOGLE, testObj, function (err, googleRes) {
|
||||
if (err) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString()
|
||||
}
|
||||
};
|
||||
|
||||
res.json(resObj);
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (iap.isValidated(googleRes)) {
|
||||
var resObj = {
|
||||
ok: true,
|
||||
data: googleRes
|
||||
};
|
||||
|
||||
payments.buyGems({user:user, paymentMethod:'IAP GooglePlay'});
|
||||
|
||||
// yay good!
|
||||
res.json(resObj);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
exports.iosVerify = function(req, res, next) {
|
||||
console.info(req.body);
|
||||
|
||||
var iapBody = req.body;
|
||||
var user = res.locals.user;
|
||||
|
||||
iap.setup(function (error) {
|
||||
if (error) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error'
|
||||
};
|
||||
|
||||
console.error('IAP Setup ERROR');
|
||||
console.error(error);
|
||||
|
||||
res.json(resObj);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// iap is ready
|
||||
iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) {
|
||||
if (err) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString()
|
||||
}
|
||||
};
|
||||
|
||||
res.json(resObj);
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (iap.isValidated(appleRes)) {
|
||||
var resObj = {
|
||||
ok: true,
|
||||
data: appleRes
|
||||
};
|
||||
|
||||
payments.buyGems({user:user, paymentMethod:'IAP AppleStore'});
|
||||
|
||||
// yay good!
|
||||
res.json(resObj);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/* @see ./routes.coffee for routing*/
|
||||
var _ = require('lodash');
|
||||
var shared = require('../../../common');
|
||||
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);
|
||||
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);
|
||||
if(isProduction) 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);
|
||||
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;
|
||||
@@ -0,0 +1,216 @@
|
||||
var nconf = require('nconf');
|
||||
var moment = require('moment');
|
||||
var async = require('async');
|
||||
var _ = require('lodash');
|
||||
var url = require('url');
|
||||
var User = require('mongoose').model('User');
|
||||
var payments = require('./index');
|
||||
var logger = require('../../logging');
|
||||
var ipn = require('paypal-ipn');
|
||||
var paypal = require('paypal-rest-sdk');
|
||||
var shared = require('../../../common');
|
||||
var mongoose = require('mongoose');
|
||||
var cc = require('coupon-code');
|
||||
|
||||
// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have
|
||||
// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created
|
||||
// there, get it's plan.id and store it in config.json
|
||||
_.each(shared.content.subscriptionBlocks, function(block){
|
||||
block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.key);
|
||||
});
|
||||
|
||||
paypal.configure({
|
||||
'mode': nconf.get("PAYPAL:mode"), //sandbox or live
|
||||
'client_id': nconf.get("PAYPAL:client_id"),
|
||||
'client_secret': nconf.get("PAYPAL:client_secret")
|
||||
});
|
||||
|
||||
var parseErr = function(res, err){
|
||||
//var error = err.response ? err.response.message || err.response.details[0].issue : err;
|
||||
var error = JSON.stringify(err);
|
||||
return res.json(400,{err:error});
|
||||
}
|
||||
|
||||
exports.createBillingAgreement = function(req,res,next){
|
||||
var sub = shared.content.subscriptionBlocks[req.query.sub];
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
if (!sub.discount) return cb(null, null);
|
||||
if (!req.query.coupon) return cb('Please provide a coupon code for this plan.');
|
||||
mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb);
|
||||
},
|
||||
function(coupon, cb){
|
||||
if (sub.discount && !coupon) return cb('Invalid coupon code.');
|
||||
var billingPlanTitle = "HabitRPG Subscription" + ' ($'+sub.price+' every '+sub.months+' months, recurring)';
|
||||
var billingAgreementAttributes = {
|
||||
"name": billingPlanTitle,
|
||||
"description": billingPlanTitle,
|
||||
"start_date": moment().add({minutes:5}).format(),
|
||||
"plan": {
|
||||
"id": sub.paypalKey
|
||||
},
|
||||
"payer": {
|
||||
"payment_method": "paypal"
|
||||
}
|
||||
};
|
||||
paypal.billingAgreement.create(billingAgreementAttributes, cb);
|
||||
}
|
||||
], function(err, billingAgreement){
|
||||
if (err) return parseErr(res, err);
|
||||
// For approving subscription via Paypal, first redirect user to: approval_url
|
||||
req.session.paypalBlock = req.query.sub;
|
||||
var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href;
|
||||
res.redirect(approval_url);
|
||||
});
|
||||
}
|
||||
|
||||
exports.executeBillingAgreement = function(req,res,next){
|
||||
var block = shared.content.subscriptionBlocks[req.session.paypalBlock];
|
||||
delete req.session.paypalBlock;
|
||||
async.auto({
|
||||
exec: function (cb) {
|
||||
paypal.billingAgreement.execute(req.query.token, {}, cb);
|
||||
},
|
||||
get_user: function (cb) {
|
||||
User.findById(req.session.userId, cb);
|
||||
},
|
||||
create_sub: ['exec', 'get_user', function (cb, results) {
|
||||
payments.createSubscription({
|
||||
user: results.get_user,
|
||||
customerId: results.exec.id,
|
||||
paymentMethod: 'Paypal',
|
||||
sub: block
|
||||
}, cb);
|
||||
}]
|
||||
},function(err){
|
||||
if (err) return parseErr(res, err);
|
||||
res.redirect('/');
|
||||
})
|
||||
}
|
||||
|
||||
exports.createPayment = function(req, res) {
|
||||
// if we're gifting to a user, put it in session for the `execute()`
|
||||
req.session.gift = req.query.gift || undefined;
|
||||
var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
var price = !gift ? 5.00
|
||||
: gift.type=='gems' ? Number(gift.gems.amount/4).toFixed(2)
|
||||
: Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2);
|
||||
var description = !gift ? "HabitRPG Gems"
|
||||
: gift.type=='gems' ? "HabitRPG Gems (Gift)"
|
||||
: shared.content.subscriptionBlocks[gift.subscription.key].months + "mo. HabitRPG Subscription (Gift)";
|
||||
var create_payment = {
|
||||
"intent": "sale",
|
||||
"payer": {
|
||||
"payment_method": "paypal"
|
||||
},
|
||||
"redirect_urls": {
|
||||
"return_url": nconf.get('BASE_URL') + '/paypal/checkout/success',
|
||||
"cancel_url": nconf.get('BASE_URL')
|
||||
},
|
||||
"transactions": [{
|
||||
"item_list": {
|
||||
"items": [{
|
||||
"name": description,
|
||||
//"sku": "1",
|
||||
"price": price,
|
||||
"currency": "USD",
|
||||
"quantity": 1
|
||||
}]
|
||||
},
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"total": price
|
||||
},
|
||||
"description": description
|
||||
}]
|
||||
};
|
||||
paypal.payment.create(create_payment, function (err, payment) {
|
||||
if (err) return parseErr(res, err);
|
||||
var link = _.find(payment.links, {rel: 'approval_url'}).href;
|
||||
res.redirect(link);
|
||||
});
|
||||
}
|
||||
|
||||
exports.executePayment = function(req, res) {
|
||||
var paymentId = req.query.paymentId,
|
||||
PayerID = req.query.PayerID,
|
||||
gift = req.session.gift ? JSON.parse(req.session.gift) : undefined;
|
||||
delete req.session.gift;
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
paypal.payment.execute(paymentId, {payer_id: PayerID}, cb);
|
||||
},
|
||||
function(payment, cb){
|
||||
async.parallel([
|
||||
function(cb2){ User.findById(req.session.userId, cb2); },
|
||||
function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); }
|
||||
], cb);
|
||||
},
|
||||
function(results, cb){
|
||||
if (_.isEmpty(results[0])) return cb("User not found when completing paypal transaction");
|
||||
var data = {user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift}
|
||||
var method = 'buyGems';
|
||||
if (gift) {
|
||||
gift.member = results[1];
|
||||
if (gift.type=='subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
payments[method](data, cb);
|
||||
}
|
||||
],function(err){
|
||||
if (err) return parseErr(res, err);
|
||||
res.redirect('/');
|
||||
})
|
||||
}
|
||||
|
||||
exports.cancelSubscription = function(req, res, next){
|
||||
var user = res.locals.user;
|
||||
if (!user.purchased.plan.customerId)
|
||||
return res.json(401, {err: "User does not have a plan subscription"});
|
||||
async.auto({
|
||||
get_cus: function(cb){
|
||||
paypal.billingAgreement.get(user.purchased.plan.customerId, cb);
|
||||
},
|
||||
verify_cus: ['get_cus', function(cb, results){
|
||||
var hasntBilledYet = results.get_cus.agreement_details.cycles_completed == "0";
|
||||
if (hasntBilledYet)
|
||||
return cb("The plan hasn't activated yet (due to a PayPal bug). It will begin "+results.get_cus.agreement_details.next_billing_date+", after which you can cancel to retain your full benefits");
|
||||
cb();
|
||||
}],
|
||||
del_cus: ['verify_cus', function(cb, results){
|
||||
paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb);
|
||||
}],
|
||||
cancel_sub: ['get_cus', 'verify_cus', function(cb, results){
|
||||
var data = {user: user, paymentMethod: 'Paypal', nextBill: results.get_cus.agreement_details.next_billing_date};
|
||||
payments.cancelSubscription(data, cb)
|
||||
}]
|
||||
}, function(err){
|
||||
if (err) return parseErr(res, err);
|
||||
res.redirect('/');
|
||||
user = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their
|
||||
* recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution
|
||||
*/
|
||||
exports.ipn = function(req, res, next) {
|
||||
console.log('IPN Called');
|
||||
res.send(200); // Must respond to PayPal IPN request with an empty 200 first
|
||||
ipn.verify(req.body, function(err, msg) {
|
||||
if (err) return logger.error(msg);
|
||||
switch (req.body.txn_type) {
|
||||
// TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead...
|
||||
case 'recurring_payment_profile_cancel':
|
||||
case 'subscr_cancel':
|
||||
User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){
|
||||
if (err) return logger.error(err);
|
||||
if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel)
|
||||
payments.cancelSubscription({user:user, paymentMethod: 'Paypal'});
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring
|
||||
// payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this
|
||||
// file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json),
|
||||
// and once for any time you need to edit the plan thereafter
|
||||
require('coffee-script');
|
||||
var path = require('path');
|
||||
var nconf = require('nconf');
|
||||
_ = require('lodash');
|
||||
nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json')));
|
||||
var paypal = require('paypal-rest-sdk');
|
||||
var blocks = require('../../../common').content.subscriptionBlocks;
|
||||
var live = nconf.get('PAYPAL:mode')=='live';
|
||||
|
||||
var OP = 'create'; // list create update remove
|
||||
|
||||
paypal.configure({
|
||||
'mode': nconf.get("PAYPAL:mode"), //sandbox or live
|
||||
'client_id': nconf.get("PAYPAL:client_id"),
|
||||
'client_secret': nconf.get("PAYPAL:client_secret")
|
||||
});
|
||||
|
||||
// https://developer.paypal.com/docs/api/#billing-plans-and-agreements
|
||||
var billingPlanTitle ="HabitRPG Subscription";
|
||||
var billingPlanAttributes = {
|
||||
"name": billingPlanTitle,
|
||||
"description": billingPlanTitle,
|
||||
"type": "INFINITE",
|
||||
"merchant_preferences": {
|
||||
"auto_bill_amount": "yes",
|
||||
"cancel_url": live ? 'https://habitrpg.com' : 'http://localhost:3000',
|
||||
"return_url": (live ? 'https://habitrpg.com' : 'http://localhost:3000') + '/paypal/subscribe/success'
|
||||
},
|
||||
payment_definitions: [{
|
||||
"type": "REGULAR",
|
||||
"frequency": "MONTH",
|
||||
"cycles": "0"
|
||||
}]
|
||||
};
|
||||
_.each(blocks, function(block){
|
||||
block.definition = _.cloneDeep(billingPlanAttributes);
|
||||
_.merge(block.definition.payment_definitions[0], {
|
||||
"name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)',
|
||||
"frequency_interval": ""+block.months,
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"value": ""+block.price
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
switch(OP) {
|
||||
case "list":
|
||||
paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){
|
||||
console.log({err:err, plans:plans});
|
||||
});
|
||||
break;
|
||||
case "get":
|
||||
paypal.billingPlan.get(nconf.get("PAYPAL:billing_plans:12"), function (err, plan) {
|
||||
console.log({err:err, plan:plan});
|
||||
})
|
||||
break;
|
||||
case "update":
|
||||
var update = {
|
||||
"op": "replace",
|
||||
"path": "/merchant_preferences",
|
||||
"value": {
|
||||
"cancel_url": "https://habitrpg.com"
|
||||
}
|
||||
};
|
||||
paypal.billingPlan.update(nconf.get("PAYPAL:billing_plans:12"), update, function (err, res) {
|
||||
console.log({err:err, plan:res});
|
||||
});
|
||||
break;
|
||||
case "create":
|
||||
paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){
|
||||
if (err) return console.log(err);
|
||||
if (plan.state == "ACTIVE")
|
||||
return console.log({err:err, plan:plan});
|
||||
var billingPlanUpdateAttributes = [{
|
||||
"op": "replace",
|
||||
"path": "/",
|
||||
"value": {
|
||||
"state": "ACTIVE"
|
||||
}
|
||||
}];
|
||||
// Activate the plan by changing status to Active
|
||||
paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){
|
||||
console.log({err:err, response:response, id:plan.id});
|
||||
});
|
||||
});
|
||||
break;
|
||||
case "remove": break;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
var nconf = require('nconf');
|
||||
var stripe = require("stripe")(nconf.get('STRIPE_API_KEY'));
|
||||
var async = require('async');
|
||||
var payments = require('./index');
|
||||
var User = require('mongoose').model('User');
|
||||
var shared = require('../../../common');
|
||||
var mongoose = require('mongoose');
|
||||
var cc = require('coupon-code');
|
||||
|
||||
/*
|
||||
Setup Stripe response when posting payment
|
||||
*/
|
||||
exports.checkout = function(req, res, next) {
|
||||
var token = req.body.id;
|
||||
var user = res.locals.user;
|
||||
var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
|
||||
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
if (sub) {
|
||||
async.waterfall([
|
||||
function(cb2){
|
||||
if (!sub.discount) return cb2(null, null);
|
||||
if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.');
|
||||
mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb2);
|
||||
},
|
||||
function(coupon, cb2){
|
||||
if (sub.discount && !coupon) return cb2('Invalid coupon code.');
|
||||
var customer = {
|
||||
email: req.body.email,
|
||||
metadata: {uuid: user._id},
|
||||
card: token,
|
||||
plan: sub.key
|
||||
};
|
||||
stripe.customers.create(customer, cb2);
|
||||
}
|
||||
], cb);
|
||||
} else {
|
||||
stripe.charges.create({
|
||||
amount: !gift ? "500" //"500" = $5
|
||||
: gift.type=='subscription' ? ""+shared.content.subscriptionBlocks[gift.subscription.key].price*100
|
||||
: ""+gift.gems.amount/4*100,
|
||||
currency: "usd",
|
||||
card: token
|
||||
}, cb);
|
||||
}
|
||||
},
|
||||
function(response, cb) {
|
||||
if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb);
|
||||
async.waterfall([
|
||||
function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2) },
|
||||
function(member, cb2){
|
||||
var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift};
|
||||
var method = 'buyGems';
|
||||
if (gift) {
|
||||
gift.member = member;
|
||||
if (gift.type=='subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
payments[method](data, cb2);
|
||||
}
|
||||
], cb);
|
||||
}
|
||||
], function(err){
|
||||
if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors
|
||||
res.send(200);
|
||||
user = token = null;
|
||||
});
|
||||
};
|
||||
|
||||
exports.subscribeCancel = function(req, res, next) {
|
||||
var user = res.locals.user;
|
||||
if (!user.purchased.plan.customerId)
|
||||
return res.json(401, {err: "User does not have a plan subscription"});
|
||||
|
||||
async.auto({
|
||||
get_cus: function(cb){
|
||||
stripe.customers.retrieve(user.purchased.plan.customerId, cb);
|
||||
},
|
||||
del_cus: ['get_cus', function(cb, results){
|
||||
stripe.customers.del(user.purchased.plan.customerId, cb);
|
||||
}],
|
||||
cancel_sub: ['get_cus', function(cb, results) {
|
||||
var data = {
|
||||
user: user,
|
||||
nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds
|
||||
paymentMethod: 'Stripe'
|
||||
};
|
||||
payments.cancelSubscription(data, cb);
|
||||
}]
|
||||
}, function(err, results){
|
||||
if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors
|
||||
res.redirect('/');
|
||||
user = null;
|
||||
});
|
||||
};
|
||||
|
||||
exports.subscribeEdit = function(req, res, next) {
|
||||
var token = req.body.id;
|
||||
var user = res.locals.user;
|
||||
var user_id = user.purchased.plan.customerId;
|
||||
var sub_id;
|
||||
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
stripe.customers.listSubscriptions(user_id, cb);
|
||||
},
|
||||
function(response, cb) {
|
||||
sub_id = response.data[0].id;
|
||||
console.warn(sub_id);
|
||||
console.warn([user_id, sub_id, { card: token }]);
|
||||
stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb);
|
||||
},
|
||||
function(response, cb) {
|
||||
user.save(cb);
|
||||
}
|
||||
], function(err, saved){
|
||||
if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors
|
||||
res.send(200);
|
||||
token = user = user_id = sub_id;
|
||||
});
|
||||
};
|
||||
@@ -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('../../common');
|
||||
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) {
|
||||
try{
|
||||
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);
|
||||
|
||||
// FOR DEBUGGING, PLEASE IGNORE
|
||||
var opStatus = null;
|
||||
|
||||
// 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){
|
||||
opStatus = 'saveUser';
|
||||
user.save(cb); // make sure to save the cron effects
|
||||
},
|
||||
function(saved, count, cb){
|
||||
opStatus = 'runQuest';
|
||||
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) {
|
||||
if(err) logging.loggly({
|
||||
error: "Cron caught",
|
||||
stack: (err.stack || err.message || err),
|
||||
body: req.body, headers: req.header,
|
||||
auth: req.headers['x-api-user'],
|
||||
originalUrl: req.originalUrl,
|
||||
opStatus: opStatus
|
||||
});
|
||||
res.locals.user = saved;
|
||||
next(err,saved);
|
||||
user = progress = quest = null;
|
||||
});
|
||||
}catch(e){
|
||||
logging.loggly({
|
||||
error: "Cron uncaught",
|
||||
stack: e.stack || e
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// 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);
|
||||
var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name}));
|
||||
_.each(req.body.emails, function(invite){
|
||||
if (invite.email) {
|
||||
var variables = [
|
||||
{name: 'LINK', content: link},
|
||||
{name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name},
|
||||
{name: 'INVITEE', content: invite.name}
|
||||
];
|
||||
// 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) return cb("Inviter not in party");
|
||||
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 ../../common/scripts/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});
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user