'use strict'; // @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 Bluebird = require('bluebird'); var utils = require('./../../libs/api-v2/utils'); var shared = require('../../../../common'); import { removeFromArray } from '../../libs/api-v3/collectionManipulators'; import { model as User, } from './../../models/user'; import { model as Group, TAVERN_ID, } from './../../models/group'; import { model as Challenge, } from './../../models/challenge'; import { model as EmailUnsubscription, } from './../../models/emailUnsubscription'; var isProd = nconf.get('NODE_ENV') === 'production'; var api = module.exports; var pushNotify = require('./pushNotifications'); var analytics = utils.analytics; /* ------------------------------------------------------------------------ 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} }; const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map(email => {return {email, canSend: true}}); /** * For parties, we want a lot of member details so we can show their avatars in the header. For guilds, we want very * limited fields - and only a sampling of the members, beacuse they can be in the thousands * @param type: 'party' or otherwise * @param q: the Mongoose query we're building up * @param additionalFields: if we want to populate some additional field not fetched normally * pass it as a string, parties only */ var populateQuery = function(type, q, additionalFields){ if (type == 'party') q.populate('members', partyFields + (additionalFields ? (' ' + additionalFields) : '')); else q.populate(guildPopulate); q.populate('leader', nameFields); 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({_id: user.party._id, type: 'party'}) .select(groupFields).exec(function(err, party){ if (err) return cb(err); if (!party) return cb(null, []); party.getTransformedData({cb: function (err, transformedParty) { if (err) return cb(err); cb(null, (transformedParty === null ? [] : [transformedParty])); // return as an array for consistent ngResource use }}); }); }, guilds: function(cb) { if (!~type.indexOf('guilds')) return cb(null, []); Group.find({_id: {'$in': user.guilds}, type:'guild'}) .select(groupFields).sort(sort).exec(function (err, guilds) { if (err) return cb(err); async.map(guilds, function (guild, cb1) { guild.getTransformedData({cb: cb1}) }, function(err, guildsTransormed) { cb(err, guildsTransormed); }); }); }, 'public': function(cb) { if (!~type.indexOf('public')) return cb(null, []); Group.find({privacy: 'public'}) .select(groupFields) .sort(sort) .lean() .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 (user.guilds.indexOf(g._id) !== -1) g._isMember = true; }); cb(null, groups); }); }, // unecessary given our ui-router setup tavern: function(cb) { if (!~type.indexOf('tavern')) return cb(null, {}); Group.findById(TAVERN_ID).select(groupFields).exec(function(err, tavern){ if (err) return cb(err); tavern.getTransformedData({cb: function (err, transformedTavern) { if (err) return cb(err); cb(null, ([transformedTavern])); // 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; let isUserGuild = user.guilds.indexOf(gid) !== -1; var q; if (gid === 'party' || gid === user.party._id) { q = Group.findOne({_id: user.party._id, type: 'party'}) } else { if (isUserGuild) { q = Group.findOne({type: 'guild', _id: gid}); } else if (gid === 'habitrpg') { q = Group.findOne({_id: TAVERN_ID}); } else { q = Group.findOne({type: 'guild', privacy: 'public', _id: gid}); } } q.populate('leader', nameFields); //populateQuery(gid, q); q.exec(function(err, group){ if (err) return next(err); if(!group){ if(gid !== 'party') return res.status(404).json({err: shared.i18n.t('messageGroupNotFound')}); // Don't send a 404 when querying for a party even if it doesn't exist // so that users with no party don't get a 404 on every access to the site return res.json(group); } group.getTransformedData({ cb: function (err, transformedGroup) { if (err) return next(err); if (!user.contributor.admin) { _purgeFlagInfoFromChat(transformedGroup, user); } //Since we have a limit on how many members are populate to the group, we want to make sure the user is always in the group var userInGroup = _.find(transformedGroup.members, function(member){ return member._id == user._id; }); if ((gid === 'party' || isUserGuild) && !userInGroup) { transformedGroup.members.splice(0,1); transformedGroup.members.push(user); } res.json(transformedGroup); }, populateMembers: group.type === 'party' ? partyFields : nameFields, populateInvites: nameFields, populateChallenges: challengeFields, }); }); }; 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.name) group.name = 'group name'; if(group.type === 'guild'){ user.guilds.push(group._id); if(user.balance < 1) return res.status(401).json({err: shared.i18n.t('messageInsufficientGems')}); group.balance = 1; user.balance--; async.waterfall([ function(cb){user.save(cb)}, function(saved,ct,cb){group.save(cb)}, function(saved,ct,cb){ saved.getTransformedData({ populateMembers: nameFields, cb, }) } ],function(err,groupTransformed){ if (err) return next(err); res.json(groupTransformed); group = user = null; }); } else{ if (user.party._id) return res.status(400).json({err:shared.i18n.t('messageGroupAlreadyInParty')}); user.party._id = group._id; user.save(function (err) { if (err) return next(err); group.save(function(err, saved) { if (err) return next(err); saved.getTransformedData({ populateMembers: nameFields, cb (err, groupTransformed) { res.json(groupTransformed); }, }); }); }) } } api.update = function(req, res, next) { var group = res.locals.group; var user = res.locals.user; if(group.leader !== user._id) return res.status(401).json({err: shared.i18n.t('messageGroupOnlyLeaderCanUpdate')}); 'name description logo logo leaderMessage leader leaderOnly'.split(' ').forEach(function(attr){ if (req.body[attr]) group[attr] = req.body[attr]; }); group.save(function(err, saved){ if (err) return next(err); res.sendStatus(204); }); } // TODO remove from api object? api.attachGroup = function(req, res, next) { var user = res.locals.user; var gid = req.params.gid === 'party' ? user.party._id : req.params.gid; if (gid === 'habitrpg') gid = TAVERN_ID; let q = Group.findOne({_id: gid}) q.exec(function(err, group){ if(err) return next(err); if(!group) return res.status(404).json({err: shared.i18n.t('messageGroupNotFound')}); if (!user.contributor.admin) { _purgeFlagInfoFromChat(group, user); } 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; let isUserGuild = user.guilds.indexOf(gid) !== -1; if (gid === 'party' || gid === user.party._id) { q = Group.findOne({_id: user.party._id, type: 'party'}) } else { if (isUserGuild) { q = Group.findOne({type: 'guild', _id: gid}); } else if (gid === 'habitrpg') { q = Group.findOne({_id: TAVERN_ID}); } else { q = Group.findOne({type: 'guild', privacy: 'public', _id: gid}); } } q.exec(function(err, group){ if (err) return next(err); if (!group && gid!=='party') return res.status(404).json({err: shared.i18n.t('messageGroupNotFound')}); 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) { if(!req.query.message) { return res.status(400).json({err: shared.i18n.t('messageGroupChatBlankMessage')}); } else { var user = res.locals.user var group = res.locals.group; if (group.type!='party' && user.flags.chatRevoked) return res.status(401).json({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); // TODO 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); 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.status(404).json({err: "Message not found!"}); if(user._id !== message.uuid && !(user.backer && user.contributor.admin)) return res.status(401).json({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.sendStatus(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.status(404).json({err: shared.i18n.t('messageGroupChatNotFound')}); if(message.uuid == user._id) return res.status(401).json({err: shared.i18n.t('messageGroupChatFlagOwnMessage')}); 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.status(401).json({err: shared.i18n.t('messageGroupChatFlagAlreadyReported')}); 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.update({_id: group._id, 'chat.id': message.id}, {'$set': { 'chat.$.flags': message.flags, 'chat.$.flagCount': message.flagCount, }}, function(err) { if (err) return next(err); utils.txnEmail(FLAG_REPORT_EMAILS, '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: "/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: "/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 == TAVERN_ID ? '/#/options/groups/tavern' : (group.type === 'guild' ? ('/#/options/groups/guilds/' + group._id) : 'party')}, ]); return res.sendStatus(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.status(404).json({err: shared.i18n.t('messageGroupChatNotFound')}); if(user.contributor.admin){ message.flagCount = 0; Group.update({_id: group._id, 'chat.id': message.id}, {'$set': { 'chat.$.flagCount': message.flagCount, }}, function(err) { if(err) return next(err); return res.sendStatus(204); }); } else { return res.status(401).json({err: shared.i18n.t('messageGroupChatAdminClearFlagCount')}) } } 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.sendStatus(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.status(404).json({err: shared.i18n.t('messageGroupChatNotFound')}); if (message.uuid == user._id) return res.status(401).json({err: shared.i18n.t('messageGroupChatLikeOwnMessage')}); if (!message.likes) message.likes = {}; if (message.likes[user._id]) { delete message.likes[user._id]; } else { message.likes[user._id] = true; } Group.update({_id: group._id, 'chat.id': message.id}, {'$set': { 'chat.$.likes': message.likes }}, function(err) { if (err) return next(err); return res.send(group.chat); }); } api.join = function(req, res, next) { var user = res.locals.user, group = res.locals.group, isUserInvited = false; 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 = {}; // Clear invite user.markModified('invitations.party'); user.party._id = group._id; user.save(); // invite new user to pending quest if (group.quest.key && !group.quest.active) { User.update({_id:user._id},{$set: {'party.quest.RSVPNeeded': true, 'party.quest.key': group.quest.key}}).exec(); group.quest.members[user._id] = undefined; group.markModified('quest.members'); } isUserInvited = true; } else if (group.type == 'guild') { var i = _.findIndex(user.invitations.guilds, {id:group._id}); if (~i){ isUserInvited = true; user.invitations.guilds.splice(i,1); user.guilds.push(group._id); user.save(); }else{ isUserInvited = group.privacy === 'private' ? false : true; if (isUserInvited) { user.guilds.push(group._id); user.save(); } } } if(!isUserInvited) return res.status(401).json({err: shared.i18n.t('messageGroupRequiresInvite')}); if (group.memberCount === 0) { group.leader = user._id; } /*if (!_.contains(group.members, user._id)){ if (group.members.length === 0) { group.leader = user._id; } group.members.push(user._id); if (group.invites.length > 0) { group.invites.splice(_.indexOf(group.invites, user._id), 1); } }*/ async.series([ function(cb){ group.save(cb); }, function(cb){ group.getTransformedData({ cb, populateMembers: group.type === 'party' ? partyFields : nameFields, populateInvites: nameFields, populateChallenges: challengeFields, }) } ], 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; var group = res.locals.group; if (group.type === 'party') { if (group.quest && group.quest.leader === user._id) { return res.json(403, 'You cannot leave your party when you have started a quest. Abort the quest first.'); } if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) { return res.json(403, 'You cannot leave party during an active quest. Please leave the quest first.'); } } // When removing the user from challenges, should we keep the tasks? var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; group.leave(user, keep) .then(() => res.sendStatus(204)) .catch(next); }; var inviteByUUIDs = function(uuids, group, req, res, next){ async.each(uuids, function(uuid, cb){ User.findById(uuid, function(err,invite){ if (err) return cb(err); if (!invite) return cb({code:400,err:'User with id "' + uuid + '" not found'}); if (group.type == 'guild') { if (_.contains(invite.guilds, group._id)) return cb({code:400, err: "User already in that group"}); if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) return cb({code:400, err:"User already invited to that group"}); sendInvite(); } else if (group.type == 'party') { if (invite.invitations && !_.isEmpty(invite.invitations.party)) return cb({code: 400,err:"User already pending invitation."}); if (invite.party && invite.party._id) { return cb({code: 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}); pushNotify.sendNotify(invite, shared.i18n.t('invitedGuild'), group.name); }else{ //req.body.type in 'guild', 'party' invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; pushNotify.sendNotify(invite, shared.i18n.t('invitedParty'), group.name); } //group.invites.push(invite._id); async.series([ function(cb){ invite.save(cb); } ], function(err, results){ if (err) return cb(err); if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ var inviterVars = utils.getUserInfo(res.locals.user, ['name', 'email']); var emailVars = [ {name: 'INVITER', content: inviterVars.name} ]; if(group.type == 'guild'){ emailVars.push( {name: 'GUILD_NAME', content: group.name}, {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'} ); }else{ emailVars.push( {name: 'PARTY_NAME', content: group.name}, {name: 'PARTY_URL', content: '/#/options/groups/party'} ) } utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars); } cb(); }); } }); }, function(err){ if(err) return err.code ? res.status(err.code).json({err: err.err}) : next(err); async.series([ function(cb) { group.save(cb); }, function(cb) { // TODO pass group from save above don't find it again, or you have to find it again in order to run populate? Group.findById(group._id).populate('leader', nameFields).exec(function (err, savedGroup) { if (err) return next(err); savedGroup.getTransformedData({ cb: function (err, transformedGroup) { if (err) return next(err); res.json(transformedGroup); }, populateMembers: savedGroup.type === 'party' ? partyFields : nameFields, populateInvites: nameFields, populateChallenges: challengeFields, }) }); } ]); }); }; var inviteByEmails = function(invites, group, req, res, next){ var usersAlreadyRegistered = []; async.each(invites, function(invite, cb){ if (invite.email) { User.findOne({$or: [ {'auth.local.email': invite.email}, {'auth.facebook.emails.value': invite.email} ]}).select({_id: true, 'preferences.emailNotifications': true}) .exec(function(err, userToContact){ if(err) return next(err); if(userToContact){ usersAlreadyRegistered.push(userToContact._id); return cb(); } // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query var link = '?partyInvite='+ utils.encrypt(JSON.stringify({id:group._id, inviter:res.locals.user._id, name:group.name})); var inviterVars = utils.getUserInfo(res.locals.user, ['name', 'email']); var variables = [ {name: 'LINK', content: link}, {name: 'INVITER', content: req.body.inviter || inviterVars.name} ]; if(group.type == 'guild'){ variables.push({name: 'GUILD_NAME', content: group.name}); } // TODO implement "users can only be invited once" // Check for the email address not to be unsubscribed EmailUnsubscription.findOne({email: invite.email}, function(err, unsubscribed){ if(err) return cb(err); if(unsubscribed) return cb(); utils.txnEmail(invite, ('invite-friend' + (group.type == 'guild' ? '-guild' : '')), variables); cb(); }); }); }else{ cb(); } }, function(err){ if(err) return err.code ? res.status(err.code).json({err: err.err}) : next(err); if (usersAlreadyRegistered.length > 0){ inviteByUUIDs(usersAlreadyRegistered, group, req, res, next); } else{ // Send only status code down the line because it doesn't need // info on invited users since they are not yet registered res.status(200).json({}); } }); }; api.invite = function(req, res, next){ var group = res.locals.group; let userParty = res.locals.user.party && res.locals.user.party._id; let userGuilds = res.locals.user.guilds; if (group.type === 'party' && userParty !== group._id) { return res.status(401).json({err: "Only a member can invite new members!"}); } if (group.type === 'guild' && group.privacy === 'private' && !_.contains(userGuilds, group._id)) { return res.status(401).json({err: "Only a member can invite new members!"}); } if (req.body.uuids) { inviteByUUIDs(req.body.uuids, group, req, res, next); } else if (req.body.emails) { inviteByEmails(req.body.emails, group, req, res, next) } else { return res.status(400).json({err: "Can only invite by email or uuid"}); } } api.removeMember = function(req, res, next){ var group = res.locals.group; var uuid = req.query.uuid; var message = req.query.message; var user = res.locals.user; // Send an email to the removed user with an optional message from the leader var sendMessage = function(removedUser){ if(removedUser.preferences.emailNotifications.kickedGroup !== false){ utils.txnEmail(removedUser, ('kicked-from-' + group.type), [ {name: 'GROUP_NAME', content: group.name}, {name: 'MESSAGE', content: message}, {name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'}, {name: 'PARTY_WANTED_GUILD', content: '/#/options/groups/guilds/f2db2a7f-13c5-454d-b3ee-ea1f5089e601'} ]); } } if(group.leader !== user._id){ return res.status(401).json({err: "Only group leader can remove a member!"}); } if(user._id === uuid){ return res.status(401).json({err: "You cannot remove yourself!"}); } User.findById(uuid, function(err, removedUser){ if (err) return next(err); let isMember = group._id === removedUser.party._id || _.contains(removedUser.guilds, group._id); let isInvited = group._id === removedUser.invitations.party._id || !!_.find(removedUser.invitations.guilds, {id: group._id}); if(isMember){ var update = {}; if (group.quest && group.quest.leader === uuid) { update['$set'] = { quest: { key: null, leader: null } }; } else if(group.quest && group.quest.members){ // remove member from quest update['$unset'] = {}; update['$unset']['quest.members.' + uuid] = ""; } update['$inc'] = {memberCount: -1}; Group.update({_id:group._id},update, function(err, saved){ if (err) return next(err); sendMessage(removedUser); //Mark removed users messages as seen var update = {$unset:{}}; if (group.type === 'guild') { update.$pull = {guilds: group._id}; } else { update.$unset.party = true; } update.$unset['newMessages.' + group._id] = ''; if (group.quest && group.quest.active && group.quest.leader === uuid) { update['$inc'] = {}; update['$inc']['items.quests.' + group.quest.key] = 1; } User.update({_id: removedUser._id, apiToken: removedUser.apiToken}, update).exec(); // Sending an empty 204 because Group.update doesn't return the group // see http://mongoosejs.com/docs/api.html#model_Model.update group = uuid = null; return res.sendStatus(204); }); }else if(isInvited){ var invitations = removedUser.invitations; if(group.type === 'guild'){ invitations.guilds.splice(_.indexOf(invitations.guilds, group._id), 1); }else{ invitations.party = undefined; } async.series([ function(cb){ removedUser.save(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 sendMessage(removedUser); group = uuid = null; return res.sendStatus(204); }); }else{ group = uuid = null; return res.status(400).json({err: "User not found among group's members!"}); } }); } // ------------------------------------ // Quests // ------------------------------------ function canStartQuestAutomatically (group) { // If all members are either true (accepted) or false (rejected) return true // If any member is null/undefined (undecided) return false return _.every(group.quest.members, _.isBoolean); } function questStart(req, res, next) { var group = res.locals.group; var force = req.query.force; // if (group.quest.active) return res.status(400).json({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; User.findOne({_id: m}, {pushDevices: 1}, function(err, user){ pushNotify.sendNotify(user, "HabitRPG", shared.i18n.t('questStarted') + ": "+ quest.text() ); }); } else { updates['$set']['party.quest'] = Group.cleanQuestProgress(); } parallel.push(function(cb2){ User.update({_id:m},updates,cb2); }); }); group.quest.active = true; if (quest.boss) { group.quest.progress.hp = quest.boss.hp; if (quest.boss.rage) group.quest.progress.rage = 0; } else { group.quest.progress.collect = collected; } group.quest.members = questMembers; group.markModified('quest'); // members & progress.collect are both Mixed types parallel.push(function(cb2){group.save(cb2)}); parallel.push(function(cb){ // Fetch user.auth to send email, then remove it from data sent to the client populateQuery(group.type, Group.findById(group._id), 'auth.facebook auth.local').exec(cb); }); async.parallel(parallel,function(err, results){ if (err) return next(err); var lastIndex = results.length -1; var groupClone = clone(group); groupClone.members = results[lastIndex].members; // Send quest started email var usersToEmail = groupClone.members.filter(function(user){ return ( user.preferences.emailNotifications.questStarted !== false && user._id !== res.locals.user._id && group.quest.members[user._id] == true ) }); utils.txnEmail(usersToEmail, 'quest-started', [ {name: 'PARTY_URL', content: '/#/options/groups/party'} ]); _.each(groupClone.members, function(user){ // Remove sensitive data from what is sent to the public // but after having sent emails as they are needed user.auth.facebook = undefined; user.auth.local = undefined; }); group = null; return res.json(groupClone); }); } api.questAccept = function(req, res, next) { var group = res.locals.group; var user = res.locals.user; var key = req.query.key; if (!group || group.type !== 'party') return res.status(400).json({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.status(404).json({err:'Quest ' + key + ' not found'}); if (quest.lvl && user.stats.lvl < quest.lvl) return res.status(400).json({err: "You must be level "+quest.lvl+" to begin this quest."}); if (group.quest.key) return res.status(400).json({err: 'Your party is already on a quest. Try again when the current quest has ended.'}); if (!user.items.quests[key]) return res.status(400).json({err: "You don't own that quest scroll"}); let members; User.find({ 'party._id': group._id, _id: {$ne: user._id}, }).select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices') .exec().then(membersF => { members = membersF; group.markModified('quest'); group.quest.key = key; group.quest.leader = user._id; group.quest.members = {}; group.quest.members[user._id] = true; user.party.quest.RSVPNeeded = false; user.party.quest.key = key; return User.update({ 'party._id': group._id, _id: {$ne: user._id}, }, { $set: { 'party.quest.RSVPNeeded': true, 'party.quest.key': key, }, }, {multi: true}).exec(); }).then(() => { _.each(members, (member) => { group.quest.members[member._id] = null; }); if (canStartQuestAutomatically(group)) { group.startQuest(user).then(() => { return Bluebird.all([group.save(), user.save()]) }) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { if (err) return next(err); res.json(groupTransformed); }, populateMembers: group.type === 'party' ? partyFields : nameFields, }); }) .catch(next); } else { Bluebird.all([group.save(), user.save()]) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { if (err) return next(err); res.json(groupTransformed); }, populateMembers: group.type === 'party' ? partyFields : nameFields, }); }) .catch(next); } }).catch(next); // Party member accepting the invitation } else { group.markModified('quest'); group.quest.members[user._id] = true; user.party.quest.RSVPNeeded = false; if (canStartQuestAutomatically(group)) { group.startQuest(user).then(() => { return Bluebird.all([group.save(), user.save()]) }) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { if (err) return next(err); res.json(groupTransformed); }, populateMembers: group.type === 'party' ? partyFields : nameFields, }); }) .catch(next); } else { Bluebird.all([group.save(), user.save()]) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { if (err) return next(err); res.json(groupTransformed); }, populateMembers: group.type === 'party' ? partyFields : nameFields, }); }) .catch(next); } } } api.questReject = function(req, res, next) { var group = res.locals.group; var user = res.locals.user; group.quest.members[user._id] = false; group.markModified('quest.members'); user.party.quest = Group.cleanQuestProgress(); user.markModified('party.quest'); if (canStartQuestAutomatically(group)) { group.startQuest(user).then(() => { return Bluebird.all([group.save(), user.save()]) }) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { if (err) return next(err); res.json(groupTransformed); }, populateMembers: group.type === 'party' ? partyFields : nameFields, }); }) .catch(next); } else { Bluebird.all([group.save(), user.save()]) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { if (err) return next(err); res.json(groupTransformed); }, populateMembers: group.type === 'party' ? partyFields : nameFields, }); }) .catch(next); } } api.questCancel = function(req, res, next){ var group = res.locals.group; group.quest = Group.cleanGroupQuest(); group.markModified('quest'); Bluebird.all([ group.save(), User.update( {'party._id': group._id}, {$set: {'party.quest': Group.cleanQuestProgress()}}, {multi: true} ), ]).then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { if (err) return next(err); res.json(groupTransformed); }, populateMembers: group.type === 'party' ? partyFields : nameFields, }); }).catch(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. } api.questAbort = function(req, res, next){ var group = res.locals.group; let memberUpdates = User.update({ 'party._id': group._id, }, { $set: {'party.quest': Group.cleanQuestProgress()}, $inc: {_v: 1}, // TODO update middleware }, {multi: true}).exec(); let questLeaderUpdate = User.update({ _id: group.quest.leader, }, { $inc: { [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader }, }).exec(); group.quest = Group.cleanGroupQuest(); group.markModified('quest'); Bluebird.all([group.save(), memberUpdates, questLeaderUpdate]) .then(results => { results[0].getTransformedData({ cb (err, groupTransformed) { if (err) return next(err); res.json(groupTransformed); }, populateMembers: group.type === 'party' ? partyFields : nameFields, }); }) .catch(next); } api.questLeave = function(req, res, next) { // Non-member leave quest while still in progress var group = res.locals.group; var user = res.locals.user; if (!(group.quest && group.quest.active)) { return res.status(404).json({ err: 'No active quest to leave' }); } if (!(group.quest.members && group.quest.members[user._id])) { return res.status(403).json({ err: 'You are not part of the quest' }); } if (group.quest.leader === user._id) { return res.status(403).json({ err: 'Quest leader cannot leave quest' }); } group.quest.members[user._id] = false; group.markModified('quest.members'); user.party.quest = Group.cleanQuestProgress(); user.markModified('party.quest'); var groupSavePromise = Bluebird.promisify(group.save, {context: group}); var userSavePromise = Bluebird.promisify(user.save, {context: user}); Bluebird.all([groupSavePromise(), userSavePromise()]) .done(function(values) { return res.sendStatus(204); }, function(error) { return next(error); }); } function _purgeFlagInfoFromChat(group, user) { group.chat = _.filter(group.chat, function(message) { return !message.flagCount || message.flagCount < 2; }); _.each(group.chat, function (message) { if (message.flags) { var userHasFlagged = message.flags[user._id]; message.flags = {}; if (userHasFlagged) message.flags[user._id] = userHasFlagged; } }); }